Compare commits

..

41 Commits

Author SHA1 Message Date
d285ea88d8 changes made to sanitize html to overcome the VAPT alets 2026-02-09 11:22:40 +05:30
81565d294b changes made to fix the VAPT testing 2026-02-07 14:57:21 +05:30
c97053e0e3 save draft an submit rquest adddd isDraft flag to support postman submit and dealer related code commented and made it completely non-templatized for production 2026-02-06 20:12:28 +05:30
1d205a4038 deaeler request creatiom restriction removed and normal user dashboard enabled 2026-01-23 21:05:19 +05:30
fdbc8dcfa1 lint erors fixed 2026-01-23 20:43:55 +05:30
efdcb18b64 dealer claimm related code commented 2026-01-23 20:36:20 +05:30
6c5398f433 uat and production merged after adding admim template activiy type in the admin setting and soe bug fixes 2026-01-23 20:04:08 +05:30
66c33703e1 clim approval versioning enhanced view detail for proposal and completion snapshot 2026-01-19 20:03:41 +05:30
a3a142d603 multi level iteration partially implemented 2026-01-13 19:19:26 +05:30
fc46f32282 oonclusin remark fallback added 2026-01-12 15:05:49 +05:30
e8caafa7a1 uniform format ate picker added and documents preview for activity completio documents added onDMS push step 2026-01-09 18:55:40 +05:30
4c3d7fd28b block io move to request evaluation step custom shown as non-templatized for the user 2026-01-08 19:19:52 +05:30
d725e523b3 activity type added in admi settings 2026-01-07 18:56:16 +05:30
94b7c34a7a missed commit added to remote 2026-01-07 09:15:14 +05:30
22d3e8a388 draft toast added 2026-01-06 17:10:52 +05:30
985b755707 unecessary consles removed approvr performance issue card redirection issue resolved. background image added on landing screen 2026-01-05 19:10:51 +05:30
164d576ea0 templates checked for the dealer claim and dashboard added for the dealer 2026-01-02 20:17:56 +05:30
7893b52183 dealer access to the create request disabled 2025-12-31 20:11:31 +05:30
7d3b6a9da2 templates disabled for the dealer 2025-12-31 12:48:09 +05:30
c1ec261a6d build issue rsolved 2025-12-31 10:23:07 +05:30
c6bd5a19ef richtext added for dealer claim and in-step ,odal ui enhanced 2025-12-30 20:45:18 +05:30
22cb42e06e typescript issue on build resolved 2025-12-26 16:07:25 +05:30
aedba86ae3 ui enhnce on validation points 2025-12-26 15:03:24 +05:30
058ab97600 dealer dropdwn addd wit io remark mandatory in io tab 2025-12-24 20:40:29 +05:30
12f8affd15 filer based on template_type added 2025-12-23 19:24:27 +05:30
ce90fcf9ef dealer claim steps reduced and the modal popup layout changed and tanflow login added 2025-12-22 19:56:10 +05:30
01d69bb1eb delaer code pulled 2025-12-19 21:55:26 +05:30
08374f9b04 build issue resolved 2025-12-19 21:53:24 +05:30
ca3f6f33d1 build issue resolved 2025-12-19 21:48:02 +05:30
92b5584e22 we have aded the apad approvar in dealer claim 2025-12-19 21:31:35 +05:30
bbae59e271 credit note invoice mapped with webhooks 2025-12-18 21:24:37 +05:30
ecf2556c64 request detil page enhanced 2025-12-17 13:05:27 +05:30
ea6cd5151b sap implementsion for internal order and budget block 2025-12-16 19:48:54 +05:30
22223fa00c files moved for plug and play and activity capture enhanced 2025-12-15 19:36:41 +05:30
9b3194d9ca main branch code pulled and folder strucure modified for plug & play 2025-12-13 13:32:20 +05:30
ac9d4aefe4 Merge branch 'dealer_claim' of https://git.tech4biz.wiki/laxmanhalaki/Re_Figma_Code into dealer_claim 2025-12-13 10:44:28 +05:30
001d636e6c manager is pickd from the user tam and search user by dispaly name strategy 2025-12-12 21:38:01 +05:30
636dc4a1c5 typescript issues for build resolvded 2025-12-12 09:15:47 +05:30
a4f9962c38 after altering the tables rechecked the all steps working as expected 2025-12-11 20:22:46 +05:30
69c7e99d18 all modal ui added for dealerclaim wokflow step checked all 8 steps 2025-12-10 20:45:26 +05:30
0e9f8adbf6 claim management related tabls added dealers seeded untilt real dealers available tdb droped multiple times to make fresh setup 2025-12-09 20:48:08 +05:30
169 changed files with 24257 additions and 4248 deletions

View File

@ -0,0 +1,234 @@
# Complete Modular Architecture - Self-Contained Flows
## Overview
This architecture ensures that **each flow folder is completely self-contained**. Deleting a flow folder (e.g., `flows/dealer-claim/` or `flows/custom/`) removes **ALL** related code for that flow type. No dependencies remain outside the flow folder.
## Architecture Principles
1. **Complete Self-Containment**: Each flow folder contains ALL its related code
2. **Zero External Dependencies**: Flow folders don't depend on each other
3. **Single Point of Entry**: Main RequestDetail routes to flow-specific screens
4. **True Modularity**: Delete a folder = Remove all related functionality
## Directory Structure
```
flows/
├── custom/ # Custom Request Flow (COMPLETE)
│ ├── components/
│ │ ├── request-detail/
│ │ │ ├── OverviewTab.tsx # Custom overview
│ │ │ └── WorkflowTab.tsx # Custom workflow
│ │ └── request-creation/
│ │ └── CreateRequest.tsx # Custom creation
│ ├── pages/
│ │ └── RequestDetail.tsx # COMPLETE Custom RequestDetail screen
│ ├── hooks/ # Custom-specific hooks (future)
│ ├── services/ # Custom-specific services (future)
│ ├── utils/ # Custom-specific utilities (future)
│ ├── types/ # Custom-specific types (future)
│ └── index.ts # Exports all Custom components
├── dealer-claim/ # Dealer Claim Flow (COMPLETE)
│ ├── components/
│ │ ├── request-detail/
│ │ │ ├── OverviewTab.tsx # Dealer claim overview
│ │ │ ├── WorkflowTab.tsx # Dealer claim workflow
│ │ │ ├── IOTab.tsx # IO management
│ │ │ ├── claim-cards/ # All dealer claim cards
│ │ │ └── modals/ # All dealer claim modals
│ │ └── request-creation/
│ │ └── ClaimManagementWizard.tsx
│ ├── pages/
│ │ └── RequestDetail.tsx # COMPLETE Dealer Claim RequestDetail screen
│ ├── hooks/ # Dealer claim hooks (future)
│ ├── services/ # Dealer claim services (future)
│ ├── utils/ # Dealer claim utilities (future)
│ ├── types/ # Dealer claim types (future)
│ └── index.ts # Exports all Dealer Claim components
├── shared/ # Shared Components (Flow-Agnostic)
│ └── components/
│ └── request-detail/
│ ├── DocumentsTab.tsx # Used by all flows
│ ├── ActivityTab.tsx # Used by all flows
│ ├── WorkNotesTab.tsx # Used by all flows
│ ├── SummaryTab.tsx # Used by all flows
│ ├── RequestDetailHeader.tsx
│ ├── QuickActionsSidebar.tsx
│ └── RequestDetailModals.tsx
└── index.ts # Flow registry and routing utilities
```
## How It Works
### Main RequestDetail Router
The main `pages/RequestDetail/RequestDetail.tsx` is now a **simple router**:
```typescript
// 1. Fetches request to determine flow type
const flowType = getRequestFlowType(apiRequest);
// 2. Gets the appropriate RequestDetail screen from flow registry
const RequestDetailScreen = getRequestDetailScreen(flowType);
// 3. Renders the flow-specific screen
return <RequestDetailScreen {...props} />;
```
### Flow-Specific RequestDetail Screens
Each flow has its **own complete RequestDetail screen**:
- `flows/custom/pages/RequestDetail.tsx` - Complete custom request detail
- `flows/dealer-claim/pages/RequestDetail.tsx` - Complete dealer claim detail
Each screen:
- Uses its own flow-specific components
- Uses shared components from `flows/shared/`
- Is completely self-contained
- Can be deleted without affecting other flows
## Deleting a Flow Type
To completely remove a flow type (e.g., Dealer Claim):
### Step 1: Delete the Flow Folder
```bash
# Delete the entire folder
rm -rf src/flows/dealer-claim/
```
### Step 2: Update Flow Registry
```typescript
// src/flows/index.ts
export const FlowRegistry = {
CUSTOM: CustomFlow,
// DEALER_CLAIM removed - all code is gone!
} as const;
export function getRequestDetailScreen(flowType: RequestFlowType) {
switch (flowType) {
// case 'DEALER_CLAIM': removed
case 'CUSTOM':
default:
return CustomFlow.CustomRequestDetail;
}
}
```
### Step 3: Update Type Definitions (Optional)
```typescript
// src/utils/requestTypeUtils.ts
export type RequestFlowType = 'CUSTOM'; // 'DEALER_CLAIM' removed
```
**That's it!** All dealer claim related code is gone:
- ✅ RequestDetail screen deleted
- ✅ All components deleted
- ✅ All modals deleted
- ✅ All cards deleted
- ✅ All creation wizards deleted
- ✅ No orphaned code remains
## What's Inside Each Flow Folder
### Custom Flow (`flows/custom/`)
- ✅ Request Detail Screen (`pages/RequestDetail.tsx`)
- ✅ Request Detail Components (OverviewTab, WorkflowTab)
- ✅ Request Creation Component (CreateRequest)
- 🔜 Custom-specific hooks
- 🔜 Custom-specific services
- 🔜 Custom-specific utilities
- 🔜 Custom-specific types
### Dealer Claim Flow (`flows/dealer-claim/`)
- ✅ Request Detail Screen (`pages/RequestDetail.tsx`)
- ✅ Request Detail Components (OverviewTab, WorkflowTab, IOTab)
- ✅ Request Detail Cards (5 cards)
- ✅ Request Detail Modals (7 modals)
- ✅ Request Creation Component (ClaimManagementWizard)
- 🔜 Dealer claim-specific hooks
- 🔜 Dealer claim-specific services
- 🔜 Dealer claim-specific utilities
- 🔜 Dealer claim-specific types
## Benefits
1. **True Modularity**: Delete folder = Remove all functionality
2. **No Orphaned Code**: All related code is in one place
3. **Easy Maintenance**: Find everything for a flow in its folder
4. **Independent Development**: Work on flows without affecting others
5. **Clear Boundaries**: Know exactly what belongs to which flow
6. **Simple Removal**: Remove a flow type in 2 steps
## File Organization Rules
### ✅ Flow-Specific → Flow Folder
- RequestDetail screen → `flows/{flow}/pages/RequestDetail.tsx`
- Request detail components → `flows/{flow}/components/request-detail/`
- Request creation → `flows/{flow}/components/request-creation/`
- Flow-specific hooks → `flows/{flow}/hooks/`
- Flow-specific services → `flows/{flow}/services/`
- Flow-specific utils → `flows/{flow}/utils/`
- Flow-specific types → `flows/{flow}/types/`
### ✅ Shared → Shared Folder
- Components used by ALL flows → `flows/shared/components/`
### ✅ Routing → Main RequestDetail
- Flow detection and routing → `pages/RequestDetail/RequestDetail.tsx`
## Example: Adding a New Flow Type
1. **Create folder structure**:
```
flows/vendor-payment/
├── components/
│ ├── request-detail/
│ └── request-creation/
├── pages/
│ └── RequestDetail.tsx # Complete screen
└── index.ts
```
2. **Update registry** (`flows/index.ts`):
```typescript
import * as VendorPaymentFlow from './vendor-payment';
export const FlowRegistry = {
CUSTOM: CustomFlow,
DEALER_CLAIM: DealerClaimFlow,
VENDOR_PAYMENT: VendorPaymentFlow,
};
```
3. **That's it!** The flow is now plug-and-play.
## Example: Removing a Flow Type
1. **Delete folder**: `rm -rf flows/dealer-claim/`
2. **Update registry**: Remove from `FlowRegistry` and `getRequestDetailScreen()`
3. **Done!** All dealer claim code is removed.
## Current Status
**Completed**:
- Custom flow folder with RequestDetail screen
- Dealer claim flow folder with RequestDetail screen
- Main RequestDetail router
- Flow registry with routing
- Shared components folder
🔜 **Future Enhancements**:
- Move flow-specific hooks to flow folders
- Move flow-specific services to flow folders
- Move flow-specific utilities to flow folders
- Move flow-specific types to flow folders
## Conclusion
The architecture is now **truly modular and self-contained**. Each flow folder is a complete, independent module. Deleting a folder removes all related code with zero dependencies remaining. This makes the codebase maintainable, scalable, and easy to understand.

194
FLOW_DELETION_GUIDE.md Normal file
View File

@ -0,0 +1,194 @@
# Flow Deletion Guide - Complete Removal
## Overview
This guide explains how to completely remove a flow type from the application. The architecture ensures that **deleting a flow folder removes ALL related code** with zero dependencies remaining.
## Architecture Guarantee
✅ **Each flow folder is completely self-contained**
- All components, screens, hooks, services, utils, types are in the flow folder
- No dependencies on the flow folder from outside (except the registry)
- Deleting a folder = Removing all related functionality
## How to Delete a Flow Type
### Example: Removing Dealer Claim Flow
#### Step 1: Delete the Flow Folder
```bash
# Delete the entire dealer-claim folder
rm -rf src/flows/dealer-claim/
```
**What gets deleted:**
- ✅ `pages/RequestDetail.tsx` - Complete dealer claim detail screen
- ✅ All request detail components (OverviewTab, WorkflowTab, IOTab)
- ✅ All claim cards (5 cards)
- ✅ All modals (7 modals)
- ✅ Request creation wizard
- ✅ All future hooks, services, utils, types
#### Step 2: Update Flow Registry
```typescript
// src/flows/index.ts
// Remove import
// import * as DealerClaimFlow from './dealer-claim';
// Update FlowRegistry
export const FlowRegistry = {
CUSTOM: CustomFlow,
// DEALER_CLAIM: DealerClaimFlow, // REMOVED
} as const;
// Update getRequestDetailScreen()
export function getRequestDetailScreen(flowType: RequestFlowType) {
switch (flowType) {
// case 'DEALER_CLAIM': // REMOVED
// return DealerClaimFlow.DealerClaimRequestDetail;
case 'CUSTOM':
default:
return CustomFlow.CustomRequestDetail;
}
}
// Update other functions similarly
export function getOverviewTab(flowType: RequestFlowType) {
switch (flowType) {
// case 'DEALER_CLAIM': // REMOVED
// return DealerClaimFlow.DealerClaimOverviewTab;
case 'CUSTOM':
default:
return CustomFlow.CustomOverviewTab;
}
}
```
#### Step 3: Update Type Definitions (Optional)
```typescript
// src/utils/requestTypeUtils.ts
// Remove from type union
export type RequestFlowType = 'CUSTOM'; // 'DEALER_CLAIM' removed
// Remove detection function (optional - can keep for backward compatibility)
// export function isDealerClaimRequest(request: any): boolean { ... }
// Update getRequestFlowType()
export function getRequestFlowType(request: any): RequestFlowType {
// if (isDealerClaimRequest(request)) return 'DEALER_CLAIM'; // REMOVED
return 'CUSTOM';
}
```
#### Step 4: Update Navigation (If Needed)
```typescript
// src/utils/requestNavigation.ts
export function navigateToCreateRequest(
navigate: NavigateFunction,
flowType: RequestFlowType = 'CUSTOM'
): void {
// Remove dealer claim case
// if (flowType === 'DEALER_CLAIM') {
// return '/claim-management';
// }
return '/new-request';
}
```
#### Step 5: Remove Routes (If Needed)
```typescript
// src/App.tsx
// Remove dealer claim route
// <Route
// path="/claim-management"
// element={<ClaimManagementWizard ... />}
// />
```
**That's it!** All dealer claim code is completely removed.
## Verification Checklist
After deleting a flow, verify:
- [ ] Flow folder deleted
- [ ] FlowRegistry updated
- [ ] All `get*()` functions updated
- [ ] Type definitions updated (optional)
- [ ] Navigation updated (if needed)
- [ ] Routes removed (if needed)
- [ ] No broken imports
- [ ] Application compiles successfully
- [ ] No references to deleted flow in codebase
## What Happens When You Delete a Flow
### ✅ Removed
- Complete RequestDetail screen
- All flow-specific components
- All flow-specific modals
- All flow-specific cards
- Request creation wizard
- All flow-specific code
### ✅ Still Works
- Other flow types continue working
- Shared components remain
- Main RequestDetail router handles remaining flows
- Navigation for remaining flows
### ✅ No Orphaned Code
- No broken imports
- No dangling references
- No unused components
- Clean removal
## Current Flow Structure
### Custom Flow (`flows/custom/`)
**Contains:**
- `pages/RequestDetail.tsx` - Complete custom request detail screen
- `components/request-detail/` - Custom detail components
- `components/request-creation/` - Custom creation component
**To remove:** Delete `flows/custom/` folder and update registry
### Dealer Claim Flow (`flows/dealer-claim/`)
**Contains:**
- `pages/RequestDetail.tsx` - Complete dealer claim detail screen
- `components/request-detail/` - Dealer claim detail components
- `components/request-detail/claim-cards/` - 5 claim cards
- `components/request-detail/modals/` - 7 modals
- `components/request-creation/` - Claim management wizard
**To remove:** Delete `flows/dealer-claim/` folder and update registry
## Benefits of This Architecture
1. **True Modularity**: Each flow is independent
2. **Easy Removal**: Delete folder + update registry = Done
3. **No Side Effects**: Removing one flow doesn't affect others
4. **Clear Ownership**: Know exactly what belongs to which flow
5. **Maintainable**: All related code in one place
6. **Scalable**: Easy to add new flows
## Example: Complete Removal
```bash
# 1. Delete folder
rm -rf src/flows/dealer-claim/
# 2. Update registry (remove 3 lines)
# 3. Update type (remove 1 line)
# 4. Done! All dealer claim code is gone.
```
**Time to remove a flow:** ~2 minutes
## Conclusion
The architecture ensures that **deleting a flow folder removes ALL related code**. There are no dependencies, no orphaned files, and no cleanup needed. Each flow is a complete, self-contained module that can be added or removed independently.

View File

@ -0,0 +1,220 @@
# Complete Flow Segregation - Implementation Summary
## Overview
This document describes the **complete segregation** of request flows into dedicated folders. Each flow type (CUSTOM, DEALER_CLAIM) now has ALL its related components, hooks, services, utilities, and types in its own folder. Only truly shared components remain in the `shared/` folder.
## What Was Done
### 1. Created Complete Folder Structure
#### Custom Flow (`src/flows/custom/`)
```
custom/
├── components/
│ ├── request-detail/
│ │ ├── OverviewTab.tsx # Custom request overview
│ │ └── WorkflowTab.tsx # Custom request workflow
│ └── request-creation/
│ └── CreateRequest.tsx # Custom request creation
└── index.ts # Exports all custom components
```
#### Dealer Claim Flow (`src/flows/dealer-claim/`)
```
dealer-claim/
├── components/
│ ├── request-detail/
│ │ ├── OverviewTab.tsx # Dealer claim overview
│ │ ├── WorkflowTab.tsx # Dealer claim workflow
│ │ ├── IOTab.tsx # IO management (dealer claim specific)
│ │ ├── claim-cards/ # All dealer claim cards
│ │ │ ├── ActivityInformationCard.tsx
│ │ │ ├── DealerInformationCard.tsx
│ │ │ ├── ProcessDetailsCard.tsx
│ │ │ ├── ProposalDetailsCard.tsx
│ │ │ └── RequestInitiatorCard.tsx
│ │ └── modals/ # All dealer claim modals
│ │ ├── CreditNoteSAPModal.tsx
│ │ ├── DealerCompletionDocumentsModal.tsx
│ │ ├── DealerProposalSubmissionModal.tsx
│ │ ├── DeptLeadIOApprovalModal.tsx
│ │ ├── EditClaimAmountModal.tsx
│ │ ├── EmailNotificationTemplateModal.tsx
│ │ └── InitiatorProposalApprovalModal.tsx
│ └── request-creation/
│ └── ClaimManagementWizard.tsx # Dealer claim creation
└── index.ts # Exports all dealer claim components
```
#### Shared Components (`src/flows/shared/`)
```
shared/
└── components/
└── request-detail/
├── DocumentsTab.tsx # Used by all flows
├── ActivityTab.tsx # Used by all flows
├── WorkNotesTab.tsx # Used by all flows
├── SummaryTab.tsx # Used by all flows
├── RequestDetailHeader.tsx # Used by all flows
├── QuickActionsSidebar.tsx # Used by all flows
└── RequestDetailModals.tsx # Used by all flows
```
### 2. Updated Flow Registry
The flow registry (`src/flows/index.ts`) now:
- Exports all flow modules
- Provides utility functions to get flow-specific components
- Includes `getCreateRequestComponent()` for request creation
- Exports `SharedComponents` for shared components
### 3. Updated RequestDetail Component
The `RequestDetail` component now:
- Uses flow registry to get flow-specific components
- Imports shared components from `SharedComponents`
- Dynamically loads appropriate tabs based on flow type
- Maintains backward compatibility
## File Organization Rules
### ✅ Flow-Specific Files → Flow Folders
**Custom Flow:**
- Custom request creation wizard
- Custom request detail tabs (Overview, Workflow)
- Custom request hooks (future)
- Custom request services (future)
- Custom request utilities (future)
- Custom request types (future)
**Dealer Claim Flow:**
- Dealer claim creation wizard
- Dealer claim detail tabs (Overview, Workflow, IO)
- Dealer claim cards (Activity, Dealer, Process, Proposal, Initiator)
- Dealer claim modals (all 7 modals)
- Dealer claim hooks (future)
- Dealer claim services (future)
- Dealer claim utilities (future)
- Dealer claim types (future)
### ✅ Shared Files → Shared Folder
**Shared Components:**
- DocumentsTab (used by all flows)
- ActivityTab (used by all flows)
- WorkNotesTab (used by all flows)
- SummaryTab (used by all flows)
- RequestDetailHeader (used by all flows)
- QuickActionsSidebar (used by all flows)
- RequestDetailModals (used by all flows)
## Usage Examples
### Getting Flow-Specific Components
```typescript
import { getOverviewTab, getWorkflowTab, getCreateRequestComponent } from '@/flows';
import { getRequestFlowType } from '@/utils/requestTypeUtils';
const flowType = getRequestFlowType(request);
const OverviewTab = getOverviewTab(flowType);
const WorkflowTab = getWorkflowTab(flowType);
const CreateRequest = getCreateRequestComponent(flowType);
```
### Using Shared Components
```typescript
import { SharedComponents } from '@/flows';
const { DocumentsTab, ActivityTab, WorkNotesTab, SummaryTab } = SharedComponents;
```
### Direct Access to Flow Components
```typescript
import { CustomFlow, DealerClaimFlow } from '@/flows';
// Custom flow
<CustomFlow.CustomOverviewTab {...props} />
<CustomFlow.CustomCreateRequest {...props} />
// Dealer claim flow
<DealerClaimFlow.DealerClaimOverviewTab {...props} />
<DealerClaimFlow.IOTab {...props} />
<DealerClaimFlow.ClaimManagementWizard {...props} />
```
## Benefits
1. **Complete Segregation**: Each flow is completely isolated
2. **Easy Navigation**: All files for a flow type are in one place
3. **Maintainability**: Changes to one flow don't affect others
4. **Scalability**: Easy to add new flow types
5. **Clarity**: Clear separation between flow-specific and shared code
6. **Type Safety**: TypeScript ensures correct usage
## Next Steps (Future Enhancements)
1. **Move Flow-Specific Hooks**
- Custom hooks → `flows/custom/hooks/`
- Dealer claim hooks → `flows/dealer-claim/hooks/`
2. **Move Flow-Specific Services**
- Custom services → `flows/custom/services/`
- Dealer claim services → `flows/dealer-claim/services/`
3. **Move Flow-Specific Utilities**
- Custom utilities → `flows/custom/utils/`
- Dealer claim utilities → `flows/dealer-claim/utils/`
4. **Move Flow-Specific Types**
- Custom types → `flows/custom/types/`
- Dealer claim types → `flows/dealer-claim/types/`
## Files Created
### Custom Flow
- `src/flows/custom/components/request-detail/OverviewTab.tsx`
- `src/flows/custom/components/request-detail/WorkflowTab.tsx`
- `src/flows/custom/components/request-creation/CreateRequest.tsx`
- `src/flows/custom/index.ts` (updated)
### Dealer Claim Flow
- `src/flows/dealer-claim/components/request-detail/OverviewTab.tsx`
- `src/flows/dealer-claim/components/request-detail/WorkflowTab.tsx`
- `src/flows/dealer-claim/components/request-detail/IOTab.tsx`
- `src/flows/dealer-claim/components/request-detail/claim-cards/index.ts`
- `src/flows/dealer-claim/components/request-detail/modals/index.ts`
- `src/flows/dealer-claim/components/request-creation/ClaimManagementWizard.tsx`
- `src/flows/dealer-claim/index.ts` (updated)
### Shared Components
- `src/flows/shared/components/request-detail/DocumentsTab.tsx`
- `src/flows/shared/components/request-detail/ActivityTab.tsx`
- `src/flows/shared/components/request-detail/WorkNotesTab.tsx`
- `src/flows/shared/components/request-detail/SummaryTab.tsx`
- `src/flows/shared/components/request-detail/RequestDetailHeader.tsx`
- `src/flows/shared/components/request-detail/QuickActionsSidebar.tsx`
- `src/flows/shared/components/request-detail/RequestDetailModals.tsx`
- `src/flows/shared/components/index.ts` (updated)
### Registry
- `src/flows/index.ts` (updated with new structure)
## Files Modified
- `src/pages/RequestDetail/RequestDetail.tsx` - Uses new flow structure
- `src/flows/README.md` - Updated with complete segregation documentation
## Conclusion
The complete segregation is now in place. Each flow type has its own dedicated folder with all related components. This makes it easy to:
- Find all files related to a specific flow type
- Maintain and update flow-specific code
- Add new flow types without affecting existing ones
- Understand what is shared vs. flow-specific
The architecture is now truly modular and plug-and-play!

View File

@ -0,0 +1,174 @@
# Flow Structure at Source Level - Complete Guide
## Overview
Flow folders are now at the **`src/` level** for maximum visibility and easy removal. This makes it immediately clear what flows exist and makes deletion trivial.
## Directory Structure
```
src/
├── custom/ # ✅ Custom Request Flow
│ ├── components/
│ │ ├── request-detail/ # Custom detail components
│ │ └── request-creation/ # Custom creation component
│ ├── pages/
│ │ └── RequestDetail.tsx # Complete custom request detail screen
│ └── index.ts # Exports all custom components
├── dealer-claim/ # ✅ Dealer Claim Flow
│ ├── components/
│ │ ├── request-detail/ # Dealer claim detail components
│ │ │ ├── claim-cards/ # 5 claim cards
│ │ │ └── modals/ # 7 modals
│ │ └── request-creation/ # Claim management wizard
│ ├── pages/
│ │ └── RequestDetail.tsx # Complete dealer claim detail screen
│ └── index.ts # Exports all dealer claim components
├── shared/ # ✅ Shared Components
│ └── components/
│ └── request-detail/ # Components used by all flows
└── flows.ts # ✅ Flow registry and routing
```
## Key Benefits
### 1. Maximum Visibility
- Flow folders are directly visible at `src/` level
- No nested paths to navigate
- Clear separation from other code
### 2. Easy Removal
- Delete `src/custom/` → All custom code gone
- Delete `src/dealer-claim/` → All dealer claim code gone
- Update `src/flows.ts` → Done!
### 3. Complete Self-Containment
- Each flow folder contains ALL its code
- No dependencies outside the folder (except registry)
- Future hooks, services, utils, types go in flow folders
## How to Use
### Importing Flow Components
```typescript
// From flow registry
import { getRequestDetailScreen, CustomFlow, DealerClaimFlow } from '@/flows';
// Direct from flow folders
import { CustomRequestDetail } from '@/custom';
import { DealerClaimRequestDetail } from '@/dealer-claim';
// Shared components
import { SharedComponents } from '@/shared/components';
```
### Main RequestDetail Router
The main `src/pages/RequestDetail/RequestDetail.tsx` routes to flow-specific screens:
```typescript
const flowType = getRequestFlowType(apiRequest);
const RequestDetailScreen = getRequestDetailScreen(flowType);
return <RequestDetailScreen {...props} />;
```
## Deleting a Flow
### Step 1: Delete Folder
```bash
# Delete entire flow folder
rm -rf src/dealer-claim/
```
### Step 2: Update Registry
```typescript
// src/flows.ts
// Remove: import * as DealerClaimFlow from './dealer-claim';
// Remove: DEALER_CLAIM: DealerClaimFlow,
// Update: getRequestDetailScreen() to remove dealer claim case
```
**That's it!** All dealer claim code is completely removed.
## File Locations
### Custom Flow (`src/custom/`)
- `pages/RequestDetail.tsx` - Complete custom request detail screen
- `components/request-detail/OverviewTab.tsx`
- `components/request-detail/WorkflowTab.tsx`
- `components/request-creation/CreateRequest.tsx`
- `index.ts` - Exports all custom components
### Dealer Claim Flow (`src/dealer-claim/`)
- `pages/RequestDetail.tsx` - Complete dealer claim detail screen
- `components/request-detail/OverviewTab.tsx`
- `components/request-detail/WorkflowTab.tsx`
- `components/request-detail/IOTab.tsx`
- `components/request-detail/claim-cards/` - 5 cards
- `components/request-detail/modals/` - 7 modals
- `components/request-creation/ClaimManagementWizard.tsx`
- `index.ts` - Exports all dealer claim components
### Shared Components (`src/shared/`)
- `components/request-detail/DocumentsTab.tsx`
- `components/request-detail/ActivityTab.tsx`
- `components/request-detail/WorkNotesTab.tsx`
- `components/request-detail/SummaryTab.tsx`
- `components/request-detail/RequestDetailHeader.tsx`
- `components/request-detail/QuickActionsSidebar.tsx`
- `components/request-detail/RequestDetailModals.tsx`
- `components/index.ts` - Exports all shared components
### Flow Registry (`src/flows.ts`)
- FlowRegistry mapping
- `getRequestDetailScreen()` - Routes to flow-specific screens
- `getOverviewTab()` - Gets flow-specific overview tabs
- `getWorkflowTab()` - Gets flow-specific workflow tabs
- `getCreateRequestComponent()` - Gets flow-specific creation components
## Import Examples
```typescript
// Flow registry
import { getRequestDetailScreen } from '@/flows';
// Direct flow imports
import { CustomRequestDetail } from '@/custom';
import { DealerClaimRequestDetail } from '@/dealer-claim';
// Shared components
import { SharedComponents } from '@/shared/components';
const { DocumentsTab, ActivityTab } = SharedComponents;
```
## Adding a New Flow
1. **Create folder**: `src/vendor-payment/`
2. **Create structure**:
```
src/vendor-payment/
├── components/
│ ├── request-detail/
│ └── request-creation/
├── pages/
│ └── RequestDetail.tsx
└── index.ts
```
3. **Update `src/flows.ts`**:
```typescript
import * as VendorPaymentFlow from './vendor-payment';
export const FlowRegistry = {
CUSTOM: CustomFlow,
DEALER_CLAIM: DealerClaimFlow,
VENDOR_PAYMENT: VendorPaymentFlow,
};
```
## Conclusion
The architecture is now **completely modular at the source level**. Flow folders are directly under `src/` for maximum visibility, easy navigation, and trivial removal. Each flow is a complete, self-contained module.

View File

@ -0,0 +1,257 @@
# Modular Request Flow Architecture - Implementation Summary
## Overview
This document summarizes the implementation of a modular, plug-and-play architecture for handling different request flow types (CUSTOM and DEALER_CLAIM) in the application.
## What Was Implemented
### 1. Request Type Detection Utilities (`src/utils/requestTypeUtils.ts`)
Created centralized utilities for detecting and handling different request types:
- `isCustomRequest(request)` - Checks if a request is a custom request
- `isDealerClaimRequest(request)` - Checks if a request is a dealer claim request
- `getRequestFlowType(request)` - Returns the flow type ('CUSTOM' | 'DEALER_CLAIM')
- `getRequestDetailRoute(requestId, request?)` - Gets the appropriate route for request detail
- `getCreateRequestRoute(flowType)` - Gets the route for creating a new request
### 2. Global Navigation Utility (`src/utils/requestNavigation.ts`)
Created a single point of navigation logic for all request-related routes:
- `navigateToRequest(options)` - Main navigation function that handles:
- Draft requests (routes to edit)
- Different flow types
- Status-based routing
- `navigateToCreateRequest(navigate, flowType)` - Navigate to create request based on flow type
- `createRequestNavigationHandler(navigate)` - Factory function for creating navigation handlers
### 3. Modular Flow Structure (`src/flows/`)
Created a modular folder structure for different request flows:
```
src/flows/
├── custom/
│ └── index.ts # Exports Custom flow components
├── dealer-claim/
│ └── index.ts # Exports Dealer Claim flow components
├── shared/
│ └── components/ # Shared components (for future use)
└── index.ts # Flow registry and utilities
```
**Flow Registry** (`src/flows/index.ts`):
- `FlowRegistry` - Maps flow types to their modules
- `getFlowModule(flowType)` - Gets the flow module for a type
- `getOverviewTab(flowType)` - Gets the appropriate overview tab component
- `getWorkflowTab(flowType)` - Gets the appropriate workflow tab component
### 4. Updated RequestDetail Component
Modified `src/pages/RequestDetail/RequestDetail.tsx` to:
- Use flow type detection instead of hardcoded checks
- Dynamically load appropriate components based on flow type
- Support plug-and-play architecture for different flows
**Key Changes**:
- Replaced `isClaimManagementRequest()` with `getRequestFlowType()`
- Uses `getOverviewTab()` and `getWorkflowTab()` to get flow-specific components
- Maintains backward compatibility with existing components
### 5. Updated Navigation Throughout App
Updated all request card click handlers to use the global navigation utility:
**Files Updated**:
- `src/App.tsx` - Main `handleViewRequest` function
- `src/pages/ApproverPerformance/components/ApproverPerformanceRequestList.tsx`
- `src/pages/DetailedReports/DetailedReports.tsx`
All navigation now goes through `navigateToRequest()` for consistency.
## How to Use
### For Developers
### 1. Navigating to a Request
```typescript
import { navigateToRequest } from '@/utils/requestNavigation';
// In a component with navigate function
navigateToRequest({
requestId: 'REQ-123',
requestTitle: 'My Request',
status: 'pending',
request: requestObject, // Optional: helps determine flow type
navigate: navigate,
});
```
### 2. Getting Flow-Specific Components
```typescript
import { getOverviewTab, getWorkflowTab } from '@/flows';
import { getRequestFlowType } from '@/utils/requestTypeUtils';
const flowType = getRequestFlowType(request);
const OverviewTab = getOverviewTab(flowType);
const WorkflowTab = getWorkflowTab(flowType);
// Use in JSX
<OverviewTab {...props} />
<WorkflowTab {...props} />
```
### 3. Detecting Request Type
```typescript
import {
getRequestFlowType,
isCustomRequest,
isDealerClaimRequest
} from '@/utils/requestTypeUtils';
// Check specific type
if (isDealerClaimRequest(request)) {
// Handle dealer claim specific logic
}
// Get flow type
const flowType = getRequestFlowType(request); // 'CUSTOM' | 'DEALER_CLAIM'
```
## Adding a New Flow Type
To add a new flow type (e.g., "VENDOR_PAYMENT"):
### Step 1: Update Type Definitions
```typescript
// src/utils/requestTypeUtils.ts
export type RequestFlowType = 'CUSTOM' | 'DEALER_CLAIM' | 'VENDOR_PAYMENT';
export function isVendorPaymentRequest(request: any): boolean {
// Add detection logic
return request.workflowType === 'VENDOR_PAYMENT';
}
export function getRequestFlowType(request: any): RequestFlowType {
if (isVendorPaymentRequest(request)) return 'VENDOR_PAYMENT';
if (isDealerClaimRequest(request)) return 'DEALER_CLAIM';
return 'CUSTOM';
}
```
### Step 2: Create Flow Folder
```
src/flows/vendor-payment/
└── index.ts
```
```typescript
// src/flows/vendor-payment/index.ts
export { VendorPaymentOverviewTab } from '@/pages/RequestDetail/components/tabs/VendorPaymentOverviewTab';
export { VendorPaymentWorkflowTab } from '@/pages/RequestDetail/components/tabs/VendorPaymentWorkflowTab';
```
### Step 3: Update Flow Registry
```typescript
// src/flows/index.ts
import * as VendorPaymentFlow from './vendor-payment';
export const FlowRegistry = {
CUSTOM: CustomFlow,
DEALER_CLAIM: DealerClaimFlow,
VENDOR_PAYMENT: VendorPaymentFlow,
} as const;
export function getOverviewTab(flowType: RequestFlowType) {
switch (flowType) {
case 'VENDOR_PAYMENT':
return VendorPaymentFlow.VendorPaymentOverviewTab;
// ... existing cases
}
}
```
### Step 4: Create Components
Create the flow-specific components in `src/pages/RequestDetail/components/tabs/`:
- `VendorPaymentOverviewTab.tsx`
- `VendorPaymentWorkflowTab.tsx`
## Benefits
1. **Modularity**: Each flow type is isolated in its own folder
2. **Maintainability**: Changes to one flow don't affect others
3. **Scalability**: Easy to add new flow types
4. **Consistency**: Single navigation utility ensures consistent routing
5. **Type Safety**: TypeScript ensures correct usage
6. **Reusability**: Shared components can be used across flows
## Migration Notes
### Backward Compatibility
- All existing code continues to work
- `isClaimManagementRequest()` still works (now uses `isDealerClaimRequest()` internally)
- Existing components are preserved and work as before
### Breaking Changes
None. This is a non-breaking enhancement.
## Testing
To test the new architecture:
1. **Test Custom Requests**:
- Create a custom request
- Navigate to its detail page
- Verify correct components are loaded
2. **Test Dealer Claim Requests**:
- Create a dealer claim request
- Navigate to its detail page
- Verify dealer claim-specific components are loaded
3. **Test Navigation**:
- Click request cards from various pages
- Verify navigation works correctly
- Test draft requests (should route to edit)
## Future Enhancements
- [ ] Add flow-specific validation rules
- [ ] Add flow-specific API endpoints
- [ ] Add flow-specific permissions
- [ ] Add flow-specific analytics
- [ ] Add flow-specific notifications
- [ ] Create shared request card component
- [ ] Add flow-specific creation wizards
## Files Created
1. `src/utils/requestTypeUtils.ts` - Request type detection utilities
2. `src/utils/requestNavigation.ts` - Global navigation utility
3. `src/flows/custom/index.ts` - Custom flow exports
4. `src/flows/dealer-claim/index.ts` - Dealer claim flow exports
5. `src/flows/index.ts` - Flow registry
6. `src/flows/shared/components/index.ts` - Shared components placeholder
7. `src/flows/README.md` - Flow architecture documentation
## Files Modified
1. `src/pages/RequestDetail/RequestDetail.tsx` - Uses flow registry
2. `src/App.tsx` - Uses navigation utility
3. `src/pages/ApproverPerformance/components/ApproverPerformanceRequestList.tsx` - Uses navigation utility
4. `src/pages/DetailedReports/DetailedReports.tsx` - Uses navigation utility
## Conclusion
The modular architecture is now in place and ready for use. The system supports plug-and-play flow types, making it easy to add new request types in the future while maintaining clean separation of concerns.

View File

@ -0,0 +1,220 @@
# Request Detail Routing Flow - How It Works
## Overview
This document explains how `RequestDetail.tsx` routes to flow-specific detail screens based on the request type.
## Complete Flow Diagram
```
User clicks on Request Card
Navigate to /request/{requestId}
RequestDetail.tsx (Router Component)
Step 1: Fetch Request Data
├─ useRequestDetails(requestId, dynamicRequests, user)
├─ Calls API: workflowApi.getWorkflowDetails(requestId)
└─ Returns: apiRequest (full request object)
Step 2: Determine Flow Type
├─ getRequestFlowType(apiRequest)
├─ Checks: request.workflowType, request.templateType, etc.
└─ Returns: 'CUSTOM' | 'DEALER_CLAIM'
Step 3: Get Flow-Specific Screen Component
├─ getRequestDetailScreen(flowType)
├─ From: src/flows.ts
└─ Returns: CustomRequestDetail | DealerClaimRequestDetail component
Step 4: Render Flow-Specific Screen
└─ <RequestDetailScreen {...props} />
├─ If CUSTOM → src/custom/pages/RequestDetail.tsx
└─ If DEALER_CLAIM → src/dealer-claim/pages/RequestDetail.tsx
```
## Step-by-Step Breakdown
### Step 1: Fetch Request Data
**File**: `src/pages/RequestDetail/RequestDetail.tsx` (lines 75-79)
```typescript
const {
apiRequest,
loading: requestLoading,
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
```
**What happens:**
- `useRequestDetails` hook fetches the request from API
- Returns `apiRequest` object with all request data
- Includes: `workflowType`, `templateType`, `templateName`, etc.
### Step 2: Determine Flow Type
**File**: `src/pages/RequestDetail/RequestDetail.tsx` (line 94)
```typescript
const flowType = getRequestFlowType(apiRequest);
```
**File**: `src/utils/requestTypeUtils.ts` (lines 70-75)
```typescript
export function getRequestFlowType(request: any): RequestFlowType {
if (isDealerClaimRequest(request)) {
return 'DEALER_CLAIM';
}
return 'CUSTOM';
}
```
**Detection Logic:**
- Checks `request.workflowType === 'CLAIM_MANAGEMENT'``DEALER_CLAIM`
- Checks `request.templateType === 'claim-management'``DEALER_CLAIM`
- Checks `request.templateName === 'Claim Management'``DEALER_CLAIM`
- Otherwise → `CUSTOM` (default)
### Step 3: Get Flow-Specific Screen Component
**File**: `src/pages/RequestDetail/RequestDetail.tsx` (line 95)
```typescript
const RequestDetailScreen = getRequestDetailScreen(flowType);
```
**File**: `src/flows.ts` (lines 81-89)
```typescript
export function getRequestDetailScreen(flowType: RequestFlowType) {
switch (flowType) {
case 'DEALER_CLAIM':
return DealerClaimFlow.DealerClaimRequestDetail; // From src/dealer-claim/
case 'CUSTOM':
default:
return CustomFlow.CustomRequestDetail; // From src/custom/
}
}
```
**What happens:**
- `getRequestDetailScreen('DEALER_CLAIM')` → Returns `DealerClaimRequestDetail` component
- `getRequestDetailScreen('CUSTOM')` → Returns `CustomRequestDetail` component
**Component Sources:**
- `DealerClaimRequestDetail``src/dealer-claim/pages/RequestDetail.tsx`
- `CustomRequestDetail``src/custom/pages/RequestDetail.tsx`
### Step 4: Render Flow-Specific Screen
**File**: `src/pages/RequestDetail/RequestDetail.tsx` (lines 99-105)
```typescript
return (
<RequestDetailScreen
requestId={propRequestId}
onBack={onBack}
dynamicRequests={dynamicRequests}
/>
);
```
**What happens:**
- Renders the flow-specific `RequestDetail` component
- Each flow has its own complete implementation
- All props are passed through
## Import Chain
```
src/pages/RequestDetail/RequestDetail.tsx
↓ imports
src/flows.ts
↓ imports
src/custom/index.ts → exports CustomRequestDetail
↓ imports
src/custom/pages/RequestDetail.tsx → CustomRequestDetail component
OR
src/flows.ts
↓ imports
src/dealer-claim/index.ts → exports DealerClaimRequestDetail
↓ imports
src/dealer-claim/pages/RequestDetail.tsx → DealerClaimRequestDetail component
```
## Key Files
### 1. Router Component
- **File**: `src/pages/RequestDetail/RequestDetail.tsx`
- **Role**: Determines flow type and routes to appropriate screen
- **Key Functions**:
- Fetches request data
- Determines flow type
- Gets flow-specific screen component
- Renders the screen
### 2. Flow Registry
- **File**: `src/flows.ts`
- **Role**: Central registry for all flow types
- **Key Function**: `getRequestDetailScreen(flowType)` - Returns the appropriate screen component
### 3. Flow Type Detection
- **File**: `src/utils/requestTypeUtils.ts`
- **Role**: Detects request type from request data
- **Key Function**: `getRequestFlowType(request)` - Returns 'CUSTOM' or 'DEALER_CLAIM'
### 4. Flow-Specific Screens
- **Custom**: `src/custom/pages/RequestDetail.tsx``CustomRequestDetail`
- **Dealer Claim**: `src/dealer-claim/pages/RequestDetail.tsx``DealerClaimRequestDetail`
## Example Flow
### Example 1: Custom Request
```
1. User clicks Custom Request card
2. Navigate to: /request/REQ-2024-001
3. RequestDetail.tsx loads
4. useRequestDetails fetches: { workflowType: 'CUSTOM', ... }
5. getRequestFlowType() → 'CUSTOM'
6. getRequestDetailScreen('CUSTOM') → CustomRequestDetail component
7. Renders: src/custom/pages/RequestDetail.tsx
```
### Example 2: Dealer Claim Request
```
1. User clicks Dealer Claim card
2. Navigate to: /request/REQ-2024-002
3. RequestDetail.tsx loads
4. useRequestDetails fetches: { workflowType: 'CLAIM_MANAGEMENT', ... }
5. getRequestFlowType() → 'DEALER_CLAIM'
6. getRequestDetailScreen('DEALER_CLAIM') → DealerClaimRequestDetail component
7. Renders: src/dealer-claim/pages/RequestDetail.tsx
```
## Benefits of This Architecture
1. **Single Entry Point**: All requests go through `/request/{id}` route
2. **Dynamic Routing**: Flow type determined at runtime from request data
3. **Modular**: Each flow is completely self-contained
4. **Easy to Extend**: Add new flow type by:
- Create `src/new-flow/` folder
- Add to `src/flows.ts` registry
- Done!
## Summary
The routing works in **4 simple steps**:
1. **Fetch** → Get request data from API
2. **Detect** → Determine flow type from request properties
3. **Resolve** → Get flow-specific screen component from registry
4. **Render** → Display the appropriate screen
All of this happens automatically based on the request data - no manual routing needed!

222
SRC_LEVEL_FLOW_STRUCTURE.md Normal file
View File

@ -0,0 +1,222 @@
# Source-Level Flow Structure - Complete Implementation
## Overview
Flow folders are now at the **`src/` level** for maximum visibility and easy removal. Each flow folder is completely self-contained - deleting a folder removes ALL related code.
## Directory Structure
```
src/
├── custom/ # Custom Request Flow (COMPLETE)
│ ├── components/
│ │ ├── request-detail/
│ │ │ ├── OverviewTab.tsx # Custom overview
│ │ │ └── WorkflowTab.tsx # Custom workflow
│ │ └── request-creation/
│ │ └── CreateRequest.tsx # Custom creation
│ ├── pages/
│ │ └── RequestDetail.tsx # COMPLETE Custom RequestDetail screen
│ ├── hooks/ # Custom-specific hooks (future)
│ ├── services/ # Custom-specific services (future)
│ ├── utils/ # Custom-specific utilities (future)
│ ├── types/ # Custom-specific types (future)
│ └── index.ts # Exports all Custom components
├── dealer-claim/ # Dealer Claim Flow (COMPLETE)
│ ├── components/
│ │ ├── request-detail/
│ │ │ ├── OverviewTab.tsx # Dealer claim overview
│ │ │ ├── WorkflowTab.tsx # Dealer claim workflow
│ │ │ ├── IOTab.tsx # IO management
│ │ │ ├── claim-cards/ # All dealer claim cards
│ │ │ │ ├── ActivityInformationCard.tsx
│ │ │ │ ├── DealerInformationCard.tsx
│ │ │ │ ├── ProcessDetailsCard.tsx
│ │ │ │ ├── ProposalDetailsCard.tsx
│ │ │ │ └── RequestInitiatorCard.tsx
│ │ │ └── modals/ # All dealer claim modals
│ │ │ ├── CreditNoteSAPModal.tsx
│ │ │ ├── DealerCompletionDocumentsModal.tsx
│ │ │ ├── DealerProposalSubmissionModal.tsx
│ │ │ ├── DeptLeadIOApprovalModal.tsx
│ │ │ ├── EditClaimAmountModal.tsx
│ │ │ ├── EmailNotificationTemplateModal.tsx
│ │ │ └── InitiatorProposalApprovalModal.tsx
│ │ └── request-creation/
│ │ └── ClaimManagementWizard.tsx
│ ├── pages/
│ │ └── RequestDetail.tsx # COMPLETE Dealer Claim RequestDetail screen
│ ├── hooks/ # Dealer claim hooks (future)
│ ├── services/ # Dealer claim services (future)
│ ├── utils/ # Dealer claim utilities (future)
│ ├── types/ # Dealer claim types (future)
│ └── index.ts # Exports all Dealer Claim components
├── shared/ # Shared Components (Flow-Agnostic)
│ └── components/
│ └── request-detail/
│ ├── DocumentsTab.tsx # Used by all flows
│ ├── ActivityTab.tsx # Used by all flows
│ ├── WorkNotesTab.tsx # Used by all flows
│ ├── SummaryTab.tsx # Used by all flows
│ ├── RequestDetailHeader.tsx
│ ├── QuickActionsSidebar.tsx
│ └── RequestDetailModals.tsx
└── flows.ts # Flow registry and routing utilities
```
## Key Features
### ✅ Source-Level Visibility
- Flow folders are directly under `src/`
- Easy to see and navigate
- Clear separation from other code
### ✅ Complete Self-Containment
- Each flow folder contains ALL its code
- RequestDetail screens, components, modals, cards
- Future: hooks, services, utils, types
### ✅ Easy Removal
- Delete `src/custom/` → All custom code removed
- Delete `src/dealer-claim/` → All dealer claim code removed
- Update `src/flows.ts` → Done!
## How It Works
### Main RequestDetail Router
`src/pages/RequestDetail/RequestDetail.tsx` is a simple router:
```typescript
// 1. Fetches request to determine flow type
const flowType = getRequestFlowType(apiRequest);
// 2. Gets the appropriate RequestDetail screen from registry
const RequestDetailScreen = getRequestDetailScreen(flowType);
// 3. Renders the flow-specific screen
return <RequestDetailScreen {...props} />;
```
### Flow Registry
`src/flows.ts` contains the registry:
```typescript
import * as CustomFlow from './custom';
import * as DealerClaimFlow from './dealer-claim';
import * as SharedComponents from './shared/components';
export const FlowRegistry = {
CUSTOM: CustomFlow,
DEALER_CLAIM: DealerClaimFlow,
} as const;
export function getRequestDetailScreen(flowType: RequestFlowType) {
switch (flowType) {
case 'DEALER_CLAIM':
return DealerClaimFlow.DealerClaimRequestDetail;
case 'CUSTOM':
default:
return CustomFlow.CustomRequestDetail;
}
}
```
## Deleting a Flow
### Example: Remove Dealer Claim
**Step 1:** Delete folder
```bash
rm -rf src/dealer-claim/
```
**Step 2:** Update `src/flows.ts`
```typescript
// Remove import
// import * as DealerClaimFlow from './dealer-claim';
// Update FlowRegistry
export const FlowRegistry = {
CUSTOM: CustomFlow,
// DEALER_CLAIM: DealerClaimFlow, // REMOVED
} as const;
// Update getRequestDetailScreen()
export function getRequestDetailScreen(flowType: RequestFlowType) {
switch (flowType) {
// case 'DEALER_CLAIM': // REMOVED
// return DealerClaimFlow.DealerClaimRequestDetail;
case 'CUSTOM':
default:
return CustomFlow.CustomRequestDetail;
}
}
```
**Done!** All dealer claim code is removed.
## Import Paths
### Flow-Specific Imports
```typescript
// Custom flow
import { CustomRequestDetail } from '@/custom';
import { CustomOverviewTab } from '@/custom';
// Dealer claim flow
import { DealerClaimRequestDetail } from '@/dealer-claim';
import { DealerClaimOverviewTab } from '@/dealer-claim';
```
### Shared Components
```typescript
import { SharedComponents } from '@/shared/components';
const { DocumentsTab, ActivityTab } = SharedComponents;
```
### Flow Registry
```typescript
import { getRequestDetailScreen, CustomFlow, DealerClaimFlow } from '@/flows';
```
## Benefits
1. **Maximum Visibility**: Flow folders at `src/` level are immediately visible
2. **Easy Navigation**: No nested paths - just `src/custom/` or `src/dealer-claim/`
3. **Simple Removal**: Delete folder + update `flows.ts` = Done
4. **Clear Ownership**: Know exactly what belongs to which flow
5. **Complete Isolation**: Each flow is independent
6. **Future-Proof**: Easy to add hooks, services, utils, types to each flow
## Current Structure
### `src/custom/`
- ✅ Complete RequestDetail screen
- ✅ Request detail components
- ✅ Request creation component
- 🔜 Hooks, services, utils, types
### `src/dealer-claim/`
- ✅ Complete RequestDetail screen
- ✅ Request detail components
- ✅ Request detail cards (5 cards)
- ✅ Request detail modals (7 modals)
- ✅ Request creation wizard
- 🔜 Hooks, services, utils, types
### `src/shared/`
- ✅ Shared components used by all flows
### `src/flows.ts`
- ✅ Flow registry
- ✅ Routing utilities
- ✅ Component getters
## Conclusion
The architecture is now **completely modular at the source level**. Flow folders are directly under `src/` for maximum visibility and easy removal. Deleting a folder removes all related code with zero dependencies remaining.

View File

@ -0,0 +1,231 @@
# Frontend Updates for Dealer Claim Management
## Overview
This document outlines the frontend changes needed to support the new backend structure with `workflowType` field for dealer claim management.
## ✅ Completed
1. **Created utility function** (`src/utils/claimRequestUtils.ts`)
- `isClaimManagementRequest()` - Checks if request is claim management (supports both old and new formats)
- `getWorkflowType()` - Gets workflow type from request
- `shouldUseClaimManagementUI()` - Determines if claim-specific UI should be used
## 🔧 Required Updates
### 1. Update RequestDetail Component
**File**: `src/pages/RequestDetail/RequestDetail.tsx`
**Changes Needed**:
- Import `isClaimManagementRequest` from `@/utils/claimRequestUtils`
- Conditionally render `ClaimManagementOverviewTab` instead of `OverviewTab` when it's a claim management request
- Conditionally render `DealerClaimWorkflowTab` instead of `WorkflowTab` when it's a claim management request
**Example**:
```typescript
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import { ClaimManagementOverviewTab } from './components/tabs/ClaimManagementOverviewTab';
import { DealerClaimWorkflowTab } from './components/tabs/DealerClaimWorkflowTab';
// In the component:
const isClaimManagement = isClaimManagementRequest(apiRequest);
<TabsContent value="overview">
{isClaimManagement ? (
<ClaimManagementOverviewTab
request={request}
apiRequest={apiRequest}
currentUserId={(user as any)?.userId}
isInitiator={isInitiator}
/>
) : (
<OverviewTab {...overviewProps} />
)}
</TabsContent>
<TabsContent value="workflow">
{isClaimManagement ? (
<DealerClaimWorkflowTab
request={request}
user={user}
isInitiator={isInitiator}
onRefresh={refreshDetails}
/>
) : (
<WorkflowTab {...workflowProps} />
)}
</TabsContent>
```
### 2. Create Missing Utility Functions
**File**: `src/pages/RequestDetail/utils/claimDataMapper.ts` (NEW FILE)
**Functions Needed**:
- `mapToClaimManagementRequest(apiRequest, userId)` - Maps backend API response to claim management structure
- `determineUserRole(apiRequest, userId)` - Determines user's role (INITIATOR, DEALER, APPROVER, SPECTATOR)
- Helper functions to extract claim-specific data from API response
**Structure**:
```typescript
export interface ClaimManagementRequest {
activityInfo: {
activityName: string;
activityType: string;
activityDate: string;
location: string;
periodStart?: string;
periodEnd?: string;
estimatedBudget?: number;
closedExpensesBreakdown?: any[];
};
dealerInfo: {
dealerCode: string;
dealerName: string;
dealerEmail?: string;
dealerPhone?: string;
dealerAddress?: string;
};
proposalDetails?: {
costBreakup: any[];
totalEstimatedBudget: number;
timeline: string;
dealerComments?: string;
};
ioDetails?: {
ioNumber?: string;
availableBalance?: number;
blockedAmount?: number;
remainingBalance?: number;
};
dmsDetails?: {
dmsNumber?: string;
eInvoiceNumber?: string;
creditNoteNumber?: string;
};
claimAmount?: number;
}
export function mapToClaimManagementRequest(
apiRequest: any,
userId: string
): ClaimManagementRequest | null {
// Extract data from apiRequest.claimDetails (from backend)
// Map to ClaimManagementRequest structure
}
export function determineUserRole(
apiRequest: any,
userId: string
): 'INITIATOR' | 'DEALER' | 'APPROVER' | 'SPECTATOR' {
// Check if user is initiator
// Check if user is dealer (from participants or claimDetails)
// Check if user is approver (from approval levels)
// Check if user is spectator (from participants)
}
```
### 3. Update ClaimManagementOverviewTab
**File**: `src/pages/RequestDetail/components/tabs/ClaimManagementOverviewTab.tsx`
**Changes Needed**:
- Import the new utility functions from `claimDataMapper.ts`
- Remove TODO comments
- Ensure it properly handles both old and new API response formats
### 4. Update API Service
**File**: `src/services/workflowApi.ts`
**Changes Needed**:
- Add `workflowType` field to `CreateWorkflowFromFormPayload` interface
- Include `workflowType: 'CLAIM_MANAGEMENT'` when creating claim management requests
- Update response types to include `workflowType` field
**Example**:
```typescript
export interface CreateWorkflowFromFormPayload {
templateId?: string | null;
templateType: 'CUSTOM' | 'TEMPLATE';
workflowType?: string; // NEW: 'CLAIM_MANAGEMENT' | 'NON_TEMPLATIZED' | etc.
title: string;
// ... rest of fields
}
```
### 5. Update ClaimManagementWizard
**File**: `src/components/workflow/ClaimManagementWizard/ClaimManagementWizard.tsx`
**Changes Needed**:
- When submitting, include `workflowType: 'CLAIM_MANAGEMENT'` in the payload
- Update to call the new backend API endpoint for creating claim requests
### 6. Update Request List Components
**Files**:
- `src/pages/MyRequests/components/RequestCard.tsx`
- `src/pages/Requests/components/RequestCard.tsx`
- `src/pages/OpenRequests/components/RequestCard.tsx`
**Changes Needed**:
- Display template/workflow type badge based on `workflowType` field
- Support both old (`templateType`) and new (`workflowType`) formats
**Example**:
```typescript
const workflowType = request.workflowType ||
(request.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED');
{workflowType === 'CLAIM_MANAGEMENT' && (
<Badge variant="secondary">Claim Management</Badge>
)}
```
### 7. Create API Service for Claim Management
**File**: `src/services/dealerClaimApi.ts` (NEW FILE)
**Endpoints Needed**:
- `createClaimRequest(data)` - POST `/api/v1/dealer-claims`
- `getClaimDetails(requestId)` - GET `/api/v1/dealer-claims/:requestId`
- `submitProposal(requestId, data)` - POST `/api/v1/dealer-claims/:requestId/proposal`
- `submitCompletion(requestId, data)` - POST `/api/v1/dealer-claims/:requestId/completion`
- `updateIODetails(requestId, data)` - PUT `/api/v1/dealer-claims/:requestId/io`
### 8. Update Type Definitions
**File**: `src/types/index.ts`
**Changes Needed**:
- Add `workflowType?: string` to request interfaces
- Update `ClaimFormData` interface to match backend structure
## 📋 Implementation Order
1. ✅ Create `claimRequestUtils.ts` (DONE)
2. ⏳ Create `claimDataMapper.ts` with mapping functions
3. ⏳ Update `RequestDetail.tsx` to conditionally render claim components
4. ⏳ Update `workflowApi.ts` to include `workflowType`
5. ⏳ Update `ClaimManagementWizard.tsx` to send `workflowType`
6. ⏳ Create `dealerClaimApi.ts` for claim-specific endpoints
7. ⏳ Update request card components to show workflow type
8. ⏳ Test end-to-end flow
## 🔄 Backward Compatibility
The frontend should support both:
- **Old Format**: `templateType: 'claim-management'`
- **New Format**: `workflowType: 'CLAIM_MANAGEMENT'`
The `isClaimManagementRequest()` utility function handles both formats automatically.
## 📝 Notes
- All existing claim management UI components are already created
- The main work is connecting them to the new backend API structure
- The `workflowType` field allows the system to support multiple template types in the future
- All requests (claim management, non-templatized, future templates) will appear in "My Requests" and "Open Requests" automatically

View File

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

View File

@ -9,7 +9,8 @@ import { SharedSummaries } from '@/pages/SharedSummaries/SharedSummaries';
import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail'; import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail';
import { WorkNotes } from '@/pages/WorkNotes'; import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest'; import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard'; import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
import { MyRequests } from '@/pages/MyRequests'; import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests'; import { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests'; import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
@ -27,15 +28,11 @@ import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback'; import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi';
// Combined Request Database for backward compatibility import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
// This combines both custom and claim management requests import { navigateToRequest } from '@/utils/requestNavigation';
export const REQUEST_DATABASE: any = { // import { TokenManager } from '@/utils/tokenManager';
...CUSTOM_REQUEST_DATABASE,
...CLAIM_MANAGEMENT_DATABASE
};
interface AppProps { interface AppProps {
onLogout?: () => void; onLogout?: () => void;
@ -57,6 +54,43 @@ function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) =
} }
} }
// Component to conditionally render Dashboard or DealerDashboard based on user job title
function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: string) => void; onNewRequest?: () => void }) {
const [isDealer, setIsDealer] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
try {
// const userData = TokenManager.getUserData();
// // setIsDealer(userData?.jobTitle === 'Dealer');
} catch (error) {
console.error('[App] Error checking dealer status:', error);
setIsDealer(false);
} finally {
setIsLoading(false);
}
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
// Render dealer-specific dashboard if user is a dealer
if (isDealer) {
return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
}
// Render regular dashboard for all other users
return <Dashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
}
// Main Application Routes Component // Main Application Routes Component
function AppRoutes({ onLogout }: AppProps) { function AppRoutes({ onLogout }: AppProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -64,6 +98,20 @@ function AppRoutes({ onLogout }: AppProps) {
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]); const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
const [selectedRequestId, setSelectedRequestId] = useState<string>(''); const [selectedRequestId, setSelectedRequestId] = useState<string>('');
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>(''); const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
const [managerModalOpen, setManagerModalOpen] = useState(false);
const [managerModalData, setManagerModalData] = useState<{
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
managers?: Array<{
userId: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
department?: string;
}>;
message?: string;
pendingClaimData?: any;
} | null>(null);
// Retrieve dynamic requests from localStorage on mount // Retrieve dynamic requests from localStorage on mount
useEffect(() => { useEffect(() => {
@ -104,17 +152,18 @@ function AppRoutes({ onLogout }: AppProps) {
} }
}; };
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => { const handleViewRequest = (requestId: string, requestTitle?: string, status?: string, request?: any) => {
setSelectedRequestId(requestId); setSelectedRequestId(requestId);
setSelectedRequestTitle(requestTitle || 'Unknown Request'); setSelectedRequestTitle(requestTitle || 'Unknown Request');
// Check if request is a draft - if so, route to edit form instead of detail view // Use global navigation utility for consistent routing
const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT'; navigateToRequest({
if (isDraft) { requestId,
navigate(`/edit-request/${requestId}`); requestTitle,
} else { status,
navigate(`/request/${requestId}`); request,
} navigate,
});
}; };
const handleBack = () => { const handleBack = () => {
@ -135,7 +184,14 @@ function AppRoutes({ onLogout }: AppProps) {
return; return;
} }
// Regular custom request submission // If requestData has backendId, it means it came from the API flow (CreateRequest component)
// The hook already shows the toast, so we just navigate
if (requestData.backendId) {
navigate('/my-requests');
return;
}
// Regular custom request submission (old flow without API)
// Generate unique ID for the new custom request // Generate unique ID for the new custom request
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`; const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
@ -264,7 +320,101 @@ function AppRoutes({ onLogout }: AppProps) {
setApprovalAction(null); setApprovalAction(null);
}; };
const handleClaimManagementSubmit = (claimData: any) => { const handleClaimManagementSubmit = async (claimData: any, _selectedManagerEmail?: string) => {
try {
// Prepare payload for API
const payload = {
activityName: claimData.activityName,
activityType: claimData.activityType,
dealerCode: claimData.dealerCode,
dealerName: claimData.dealerName,
dealerEmail: claimData.dealerEmail || undefined,
dealerPhone: claimData.dealerPhone || undefined,
dealerAddress: claimData.dealerAddress || undefined,
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toISOString() : undefined,
location: claimData.location,
requestDescription: claimData.requestDescription,
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
estimatedBudget: claimData.estimatedBudget || undefined,
approvers: claimData.approvers || [], // Pass approvers array
};
// Call API to create claim request
const response = await createClaimRequest(payload);
// Validate response - ensure request was actually created successfully
if (!response || !response.request) {
throw new Error('Invalid response from server: Request object not found');
}
const createdRequest = response.request;
// Validate that we have at least one identifier (requestNumber or requestId)
if (!createdRequest.requestNumber && !createdRequest.requestId) {
throw new Error('Invalid response from server: Request identifier not found');
}
// Close manager modal if open
setManagerModalOpen(false);
setManagerModalData(null);
// Only show success toast if request was actually created successfully
toast.success('Claim Request Submitted', {
description: 'Your claim management request has been created successfully.',
});
// Navigate to the created request detail page using requestNumber
if (createdRequest.requestNumber) {
navigate(`/request/${createdRequest.requestNumber}`);
} else if (createdRequest.requestId) {
// Fallback to requestId if requestNumber is not available
navigate(`/request/${createdRequest.requestId}`);
} else {
// This should not happen due to validation above, but just in case
navigate('/my-requests');
}
} catch (error: any) {
console.error('[App] Error creating claim request:', error);
// Check for manager-related errors
const errorData = error?.response?.data;
const errorCode = errorData?.code || errorData?.error?.code;
if (errorCode === 'NO_MANAGER_FOUND') {
// Show modal for no manager found
setManagerModalData({
errorType: 'NO_MANAGER_FOUND',
message: errorData?.message || errorData?.error?.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
pendingClaimData: claimData,
});
setManagerModalOpen(true);
return;
}
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
// Show modal with manager list for selection
const managers = errorData?.managers || errorData?.error?.managers || [];
setManagerModalData({
errorType: 'MULTIPLE_MANAGERS_FOUND',
managers: managers,
message: errorData?.message || errorData?.error?.message || 'Multiple managers found. Please select one.',
pendingClaimData: claimData,
});
setManagerModalOpen(true);
return;
}
// Other errors - show toast
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
toast.error('Failed to Submit Claim Request', {
description: errorMessage,
});
}
// 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 // Generate unique ID for the new claim request
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`; const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
@ -456,23 +606,24 @@ function AppRoutes({ onLogout }: AppProps) {
description: 'Your claim management request has been created successfully.', description: 'Your claim management request has been created successfully.',
}); });
navigate('/my-requests'); navigate('/my-requests');
*/
}; };
return ( return (
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background"> <div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
<Routes> <Routes>
{/* Auth Callback - Must be before other routes */} {/* Auth Callback - Unified callback for both OKTA and Tanflow */}
<Route <Route
path="/login/callback" path="/login/callback"
element={<AuthCallback />} element={<AuthCallback />}
/> />
{/* Dashboard */} {/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
<Route <Route
path="/" path="/"
element={ element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} /> <DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout> </PageLayout>
} }
/> />
@ -481,7 +632,37 @@ function AppRoutes({ onLogout }: AppProps) {
path="/dashboard" path="/dashboard"
element={ element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} /> <DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout>
}
/>
{/* Admin Routes Group with Shared Layout */}
<Route
element={
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Outlet />
</PageLayout>
}
>
<Route path="/admin/create-template" element={<CreateTemplate />} />
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
<Route path="/admin/templates" element={<AdminTemplatesList />} />
</Route>
{/* Create Request from Admin Template (Dedicated Flow) */}
<Route
path="/create-admin-request/:templateId"
element={
<CreateAdminRequest />
}
/>
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout> </PageLayout>
} }
/> />
@ -697,6 +878,27 @@ function AppRoutes({ onLogout }: AppProps) {
}} }}
/> />
{/* Manager Selection Modal */}
<ManagerSelectionModal
open={managerModalOpen}
onClose={() => {
setManagerModalOpen(false);
setManagerModalData(null);
}}
onSelect={async (managerEmail: string) => {
if (managerModalData?.pendingClaimData) {
// Retry creating claim request with selected manager
// The pendingClaimData contains all the form data from the wizard
// This preserves the entire submission state while waiting for manager selection
await handleClaimManagementSubmit(managerModalData.pendingClaimData, managerEmail);
}
}}
managers={managerModalData?.managers}
errorType={managerModalData?.errorType || 'NO_MANAGER_FOUND'}
message={managerModalData?.message}
isLoading={false} // Will be set to true during retry if needed
/>
{/* Approval Action Modal */} {/* Approval Action Modal */}
{approvalAction && ( {approvalAction && (
<ApprovalActionModal <ApprovalActionModal

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -8,6 +8,7 @@
// Images // Images
export { default as ReLogo } from './images/Re_Logo.png'; export { default as ReLogo } from './images/Re_Logo.png';
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png'; export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
export { default as LandingPageImage } from './images/landing_page_image.jpg';
// Fonts // Fonts
// Add font exports here when fonts are added to the assets/fonts folder // Add font exports here when fonts are added to the assets/fonts folder

View File

@ -0,0 +1,451 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
FileText,
Plus,
Trash2,
Edit2,
Loader2,
AlertCircle,
CheckCircle,
} from 'lucide-react';
import {
getAllActivityTypes,
createActivityType,
updateActivityType,
deleteActivityType,
ActivityType
} from '@/services/adminApi';
import { toast } from 'sonner';
export function ActivityTypeManager() {
const [activityTypes, setActivityTypes] = useState<ActivityType[]>([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingActivityType, setEditingActivityType] = useState<ActivityType | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [formData, setFormData] = useState({
title: '',
itemCode: '',
taxationType: '',
sapRefNo: ''
});
useEffect(() => {
loadActivityTypes();
}, []);
const loadActivityTypes = async () => {
try {
setLoading(true);
setError(null);
const data = await getAllActivityTypes(false); // Get all including inactive
setActivityTypes(data);
} catch (err: any) {
const errorMsg = err.response?.data?.error || 'Failed to load activity types';
setError(errorMsg);
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setFormData({
title: '',
itemCode: '',
taxationType: '',
sapRefNo: ''
});
setEditingActivityType(null);
setShowAddDialog(true);
};
const handleEdit = (activityType: ActivityType) => {
setFormData({
title: activityType.title,
itemCode: activityType.itemCode || '',
taxationType: activityType.taxationType || '',
sapRefNo: activityType.sapRefNo || ''
});
setEditingActivityType(activityType);
setShowAddDialog(true);
};
const handleSave = async () => {
try {
setError(null);
if (!formData.title.trim()) {
setError('Activity type title is required');
return;
}
const payload: Partial<ActivityType> = {
title: formData.title.trim(),
itemCode: formData.itemCode.trim() || null,
taxationType: formData.taxationType.trim() || null,
sapRefNo: formData.sapRefNo.trim() || null
};
if (editingActivityType) {
// Update existing
await updateActivityType(editingActivityType.activityTypeId, payload);
setSuccessMessage('Activity type updated successfully');
toast.success('Activity type updated successfully');
} else {
// Create new
await createActivityType(payload);
setSuccessMessage('Activity type created successfully');
toast.success('Activity type created successfully');
}
await loadActivityTypes();
setShowAddDialog(false);
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err: any) {
const errorMsg = err.response?.data?.error || 'Failed to save activity type';
setError(errorMsg);
toast.error(errorMsg);
}
};
const handleDelete = async (activityType: ActivityType) => {
if (!confirm(`Delete "${activityType.title}"? This will deactivate the activity type.`)) {
return;
}
try {
setError(null);
await deleteActivityType(activityType.activityTypeId);
setSuccessMessage('Activity type deleted successfully');
toast.success('Activity type deleted successfully');
await loadActivityTypes();
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err: any) {
const errorMsg = err.response?.data?.error || 'Failed to delete activity type';
setError(errorMsg);
toast.error(errorMsg);
}
};
// Filter active and inactive activity types
const activeActivityTypes = activityTypes.filter(at => at.isActive !== false && at.isActive !== undefined);
const inactiveActivityTypes = activityTypes.filter(at => at.isActive === false);
return (
<div className="space-y-6">
{/* Success Message */}
{successMessage && (
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-300 rounded-md shadow-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="p-1.5 bg-green-500 rounded-md">
<CheckCircle className="w-4 h-4 text-white shrink-0" />
</div>
<p className="text-sm font-medium text-green-900">{successMessage}</p>
</div>
)}
{/* Error Message */}
{error && (
<div className="p-4 bg-gradient-to-r from-red-50 to-rose-50 border border-red-300 rounded-md shadow-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="p-1.5 bg-red-500 rounded-md">
<AlertCircle className="w-4 h-4 text-white shrink-0" />
</div>
<p className="text-sm font-medium text-red-900">{error}</p>
<Button
size="sm"
variant="ghost"
onClick={() => setError(null)}
className="ml-auto hover:bg-red-100"
>
Dismiss
</Button>
</div>
)}
{/* Header */}
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg sm:text-xl font-semibold text-slate-900">Activity Types</CardTitle>
<CardDescription className="text-sm">
Manage dealer claim activity types
</CardDescription>
</div>
</div>
<Button
onClick={handleAdd}
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm"
>
<Plus className="w-4 h-4" />
<span className="hidden xs:inline">Add Activity Type</span>
<span className="xs:hidden">Add</span>
</Button>
</div>
</CardHeader>
</Card>
{/* Activity Types List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : activeActivityTypes.length === 0 ? (
<Card className="shadow-lg border-0 rounded-md">
<CardContent className="p-12 text-center">
<div className="p-4 bg-slate-100 rounded-full w-20 h-20 flex items-center justify-center mx-auto mb-4">
<FileText className="w-10 h-10 text-slate-400" />
</div>
<p className="text-slate-700 font-medium text-lg">No activity types found</p>
<p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p>
<Button
onClick={handleAdd}
variant="outline"
className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
>
<Plus className="w-4 h-4" />
Add First Activity Type
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4 sm:space-y-6">
{/* Active Activity Types */}
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-3 sm:pb-4 border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">Active Activity Types</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeActivityTypes.length} active type{activeActivityTypes.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<div className="p-2 bg-green-50 rounded-md">
<CheckCircle className="w-4 h-4 text-green-600" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-4">
{activeActivityTypes.map(activityType => (
<div
key={activityType.activityTypeId}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<p className="font-semibold text-slate-900 text-sm sm:text-base">{activityType.title}</p>
<Badge variant="outline" className="bg-gradient-to-r from-green-50 to-emerald-50 text-green-800 border-green-300 text-[10px] sm:text-xs font-medium shadow-sm">
Active
</Badge>
</div>
<div className="flex flex-wrap gap-3 text-xs sm:text-sm text-slate-600">
{activityType.itemCode && (
<span className="font-medium">Item Code: <span className="text-slate-900">{activityType.itemCode}</span></span>
)}
{activityType.taxationType && (
<span className="font-medium">Taxation: <span className="text-slate-900">{activityType.taxationType}</span></span>
)}
{activityType.sapRefNo && (
<span className="font-medium">SAP Ref: <span className="text-slate-900">{activityType.sapRefNo}</span></span>
)}
{!activityType.itemCode && !activityType.taxationType && !activityType.sapRefNo && (
<span className="text-slate-500 italic">No additional details</span>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:gap-2 self-end sm:self-auto">
<Button
size="sm"
variant="ghost"
onClick={() => handleEdit(activityType)}
className="gap-1.5 hover:bg-blue-50 border border-transparent hover:border-blue-200 text-xs sm:text-sm"
>
<Edit2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden xs:inline">Edit</span>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(activityType)}
className="gap-1.5 text-red-600 hover:text-red-700 hover:bg-red-50 border border-transparent hover:border-red-200 text-xs sm:text-sm"
>
<Trash2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden xs:inline">Delete</span>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
{/* Inactive Activity Types */}
{inactiveActivityTypes.length > 0 && (
<Card className="shadow-lg border-0 rounded-md border-amber-200">
<CardHeader className="pb-3 sm:pb-4 border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">Inactive Activity Types</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{inactiveActivityTypes.length} inactive type{inactiveActivityTypes.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<div className="p-2 bg-amber-50 rounded-md">
<AlertCircle className="w-4 h-4 text-amber-600" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-4">
{inactiveActivityTypes.map(activityType => (
<div
key={activityType.activityTypeId}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<p className="font-semibold text-slate-700 text-sm sm:text-base line-through">{activityType.title}</p>
<Badge variant="outline" className="bg-gradient-to-r from-amber-50 to-orange-50 text-amber-800 border-amber-300 text-[10px] sm:text-xs font-medium shadow-sm">
Inactive
</Badge>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-2 self-end sm:self-auto">
<Button
size="sm"
variant="ghost"
onClick={() => handleEdit(activityType)}
className="gap-1.5 hover:bg-blue-50 border border-transparent hover:border-blue-200 text-xs sm:text-sm"
>
<Edit2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden xs:inline">Edit</span>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
{/* Add/Edit Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent className="sm:max-w-[550px] max-h-[90vh] rounded-lg flex flex-col p-0">
<DialogHeader className="pb-4 border-b border-slate-100 px-6 pt-6 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
<FileText className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<DialogTitle className="text-xl font-semibold text-slate-900">
{editingActivityType ? 'Edit Activity Type' : 'Add New Activity Type'}
</DialogTitle>
<DialogDescription className="text-sm text-slate-600 mt-1">
{editingActivityType ? 'Update activity type information' : 'Add a new activity type for dealer claim management'}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-5 py-6 px-6 overflow-y-auto flex-1 min-h-0">
{/* Title Field */}
<div className="space-y-2">
<Label htmlFor="title" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Title <span className="text-red-500">*</span>
</Label>
<Input
id="title"
placeholder="e.g., Riders Mania Claims, Legal Claims Reimbursement"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
/>
<p className="text-xs text-slate-500">Enter the activity type title</p>
</div>
{/* Item Code Field */}
<div className="space-y-2">
<Label htmlFor="itemCode" className="text-sm font-semibold text-slate-900">
Item Code <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="itemCode"
placeholder="e.g., 1, 2, 3"
value={formData.itemCode}
onChange={(e) => setFormData({ ...formData, itemCode: e.target.value })}
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
/>
<p className="text-xs text-slate-500">Optional item code for the activity type</p>
</div>
{/* Taxation Type Field */}
<div className="space-y-2">
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900">
Taxation Type <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="taxationType"
placeholder="e.g., GST, VAT, Exempt"
value={formData.taxationType}
onChange={(e) => setFormData({ ...formData, taxationType: e.target.value })}
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
/>
<p className="text-xs text-slate-500">Optional taxation type for the activity</p>
</div>
{/* SAP Reference Number Field */}
<div className="space-y-2">
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900">
SAP Reference Number <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="sapRefNo"
placeholder="e.g., SAP-12345"
value={formData.sapRefNo}
onChange={(e) => setFormData({ ...formData, sapRefNo: e.target.value })}
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
/>
<p className="text-xs text-slate-500">Optional SAP reference number</p>
</div>
</div>
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
<Button
variant="outline"
onClick={() => setShowAddDialog(false)}
className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!formData.title.trim()}
className="h-11 bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<FileText className="w-4 h-4 mr-2" />
{editingActivityType ? 'Update Activity Type' : 'Add Activity Type'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,3 +1,4 @@
export { ConfigurationManager } from './ConfigurationManager'; export { ConfigurationManager } from './ConfigurationManager';
export { HolidayManager } from './HolidayManager'; export { HolidayManager } from './HolidayManager';
export { ActivityTypeManager } from './ActivityTypeManager';

View File

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

View File

@ -19,6 +19,7 @@ import { ReLogo } from '@/assets';
import notificationApi, { Notification } from '@/services/notificationApi'; import notificationApi, { Notification } from '@/services/notificationApi';
import { getSocket, joinUserRoom } from '@/utils/socket'; import { getSocket, joinUserRoom } from '@/utils/socket';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { TokenManager } from '@/utils/tokenManager';
interface PageLayoutProps { interface PageLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -36,6 +37,17 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
// Check if user is a Dealer
const isDealer = useMemo(() => {
try {
const userData = TokenManager.getUserData();
return userData?.jobTitle === 'Dealer';
} catch (error) {
console.error('[PageLayout] Error checking dealer status:', error);
return false;
}
}, []);
// Get user initials for avatar // Get user initials for avatar
const getUserInitials = () => { const getUserInitials = () => {
try { try {
@ -61,19 +73,23 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{ id: 'dashboard', label: 'Dashboard', icon: Home }, { id: 'dashboard', label: 'Dashboard', icon: Home },
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests) // Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
{ id: 'requests', label: 'All Requests', icon: List }, { id: 'requests', label: 'All Requests', icon: List },
{ id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true }, { id: 'my-requests', label: 'My Requests', icon: User, adminOnly: false },
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
]; ];
// Add remaining menu items // Add remaining menu items (exclude "My Requests" for dealers)
// if (!isDealer) {
// items.push({ id: 'my-requests', label: 'My Requests', icon: User });
// }
items.push( items.push(
{ id: 'my-requests', label: 'My Requests', icon: User },
{ id: 'open-requests', label: 'Open Requests', icon: FileText }, { id: 'open-requests', label: 'Open Requests', icon: FileText },
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle }, { id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 } { id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
); );
return items; return items;
}, []); }, [isDealer]);
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen); setSidebarOpen(!sidebarOpen);
@ -260,6 +276,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</div> </div>
{/* Quick Action in Sidebar - Right below menu items */} {/* Quick Action in Sidebar - Right below menu items */}
{/* {!isDealer && ( */}
<div className="mt-6 pt-6 border-t border-gray-800 px-3"> <div className="mt-6 pt-6 border-t border-gray-800 px-3">
<Button <Button
onClick={onNewRequest} onClick={onNewRequest}
@ -270,6 +287,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
Raise New Request Raise New Request
</Button> </Button>
</div> </div>
{/* )} */}
</div> </div>
</div> </div>
</aside> </aside>
@ -298,6 +316,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</div> </div>
<div className="flex items-center gap-4 shrink-0"> <div className="flex items-center gap-4 shrink-0">
{!isDealer && (
<Button <Button
onClick={onNewRequest} onClick={onNewRequest}
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm" className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
@ -306,6 +325,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Request New Request
</Button> </Button>
)}
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}> <DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@ -0,0 +1,163 @@
/**
* Manager Selection Modal
* Shows when multiple managers are found or no manager is found
* Allows user to select a manager from the list
*/
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle, CheckCircle2, User, Mail, Building2 } from 'lucide-react';
interface Manager {
userId: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
department?: string;
}
interface ManagerSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (managerEmail: string) => void;
managers?: Manager[];
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
message?: string;
isLoading?: boolean;
}
export function ManagerSelectionModal({
open,
onClose,
onSelect,
managers = [],
errorType,
message,
isLoading = false,
}: ManagerSelectionModalProps) {
const handleSelect = (managerEmail: string) => {
onSelect(managerEmail);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{errorType === 'NO_MANAGER_FOUND' ? (
<>
<AlertCircle className="w-5 h-5 text-amber-500" />
Manager Not Found
</>
) : (
<>
<CheckCircle2 className="w-5 h-5 text-blue-500" />
Select Your Manager
</>
)}
</DialogTitle>
<DialogDescription>
{errorType === 'NO_MANAGER_FOUND' ? (
<div className="mt-2">
<p className="text-sm text-gray-600">
{message || 'No reporting manager found in the system. Please ensure your manager is correctly configured in your profile.'}
</p>
<p className="text-sm text-gray-500 mt-2">
Please contact your administrator to update your manager information, or try again later.
</p>
</div>
) : (
<div className="mt-2">
<p className="text-sm text-gray-600">
{message || 'Multiple managers were found with the same name. Please select the correct manager from the list below.'}
</p>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{errorType === 'NO_MANAGER_FOUND' ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900">
Unable to Proceed
</p>
<p className="text-sm text-amber-700 mt-1">
We couldn't find your reporting manager in the system. The claim request cannot be created without a valid manager assigned.
</p>
</div>
</div>
</div>
) : (
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{managers.map((manager) => (
<div
key={manager.userId}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => !isLoading && handleSelect(manager.email)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-gray-900">
{manager.displayName || `${manager.firstName || ''} ${manager.lastName || ''}`.trim() || 'Unknown'}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span className="truncate">{manager.email}</span>
</div>
{manager.department && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Building2 className="w-4 h-4" />
<span>{manager.department}</span>
</div>
)}
</div>
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleSelect(manager.email);
}}
disabled={isLoading}
className="flex-shrink-0"
size="sm"
>
Select
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
{errorType === 'NO_MANAGER_FOUND' ? (
<Button onClick={onClose} variant="outline">
Close
</Button>
) : (
<>
<Button onClick={onClose} variant="outline" disabled={isLoading}>
Cancel
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -183,7 +183,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left"> <Button variant="outline" className="w-full justify-start text-left">
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{formData.slaEndDate ? format(formData.slaEndDate, 'PPP') : 'Pick a date'} {formData.slaEndDate ? format(formData.slaEndDate, 'd MMM yyyy') : 'Pick a date'}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0"> <PopoverContent className="w-auto p-0">
@ -378,7 +378,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center"> <div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> <Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
Drag and drop files here, or click to browse click to browse
</p> </p>
<input <input
type="file" type="file"

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
@ -8,14 +8,16 @@ import {
Receipt, Receipt,
Package, Package,
ArrowRight, ArrowRight,
ArrowLeft,
Clock, Clock,
CheckCircle, CheckCircle,
Target, Target,
X,
Sparkles, Sparkles,
Check Check,
AlertCircle
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { TokenManager } from '../../utils/tokenManager';
interface TemplateSelectionModalProps { interface TemplateSelectionModalProps {
open: boolean; open: boolean;
@ -39,7 +41,8 @@ const AVAILABLE_TEMPLATES = [
'Document verification', 'Document verification',
'E-invoice generation', 'E-invoice generation',
'Credit note issuance' 'Credit note issuance'
] ],
disabled: false
}, },
{ {
id: 'vendor-payment', id: 'vendor-payment',
@ -55,14 +58,32 @@ const AVAILABLE_TEMPLATES = [
'Invoice verification', 'Invoice verification',
'Multi-level approvals', 'Multi-level approvals',
'Payment scheduling' 'Payment scheduling'
] ],
disabled: true,
comingSoon: true
} }
]; ];
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) { export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [isDealer, setIsDealer] = useState(false);
// Check if user is a Dealer
useEffect(() => {
const userData = TokenManager.getUserData();
setIsDealer(userData?.jobTitle === 'Dealer');
}, []);
const handleSelect = (templateId: string) => { const handleSelect = (templateId: string) => {
// Don't allow selection if user is a dealer
if (isDealer) {
return;
}
// Don't allow selection if template is disabled
const template = AVAILABLE_TEMPLATES.find(t => t.id === templateId);
if (template?.disabled) {
return;
}
setSelectedTemplate(templateId); setSelectedTemplate(templateId);
}; };
@ -84,12 +105,13 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
Choose from pre-configured templates with predefined workflows and approval chains for faster processing. Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
</DialogDescription> </DialogDescription>
{/* Custom Close button */} {/* Back arrow button - Top left */}
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-6 right-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 flex items-center justify-center transition-all hover:scale-110" className="!flex absolute top-6 left-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 items-center justify-center transition-all hover:scale-110"
aria-label="Go back"
> >
<X className="w-5 h-5 text-gray-600" /> <ArrowLeft className="w-5 h-5 text-gray-600" />
</button> </button>
{/* Full Screen Content Container */} {/* Full Screen Content Container */}
@ -117,6 +139,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
{AVAILABLE_TEMPLATES.map((template, index) => { {AVAILABLE_TEMPLATES.map((template, index) => {
const Icon = template.icon; const Icon = template.icon;
const isSelected = selectedTemplate === template.id; const isSelected = selectedTemplate === template.id;
const isDisabled = isDealer || template.disabled;
return ( return (
<motion.div <motion.div
@ -124,14 +147,16 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }} transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.03 }} whileHover={isDisabled ? {} : { scale: 1.03 }}
whileTap={{ scale: 0.98 }} whileTap={isDisabled ? {} : { scale: 0.98 }}
> >
<Card <Card
className={`cursor-pointer h-full transition-all duration-300 border-2 ${ className={`h-full transition-all duration-300 border-2 ${
isSelected isDisabled
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200' ? 'opacity-50 cursor-not-allowed border-gray-200'
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg' : isSelected
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
}`} }`}
onClick={() => handleSelect(template.id)} onClick={() => handleSelect(template.id)}
> >
@ -157,6 +182,22 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
<CardDescription className="text-sm leading-relaxed"> <CardDescription className="text-sm leading-relaxed">
{template.description} {template.description}
</CardDescription> </CardDescription>
{isDealer && (
<div className="mt-3 flex items-start gap-2 p-2 bg-amber-50 border border-amber-200 rounded-lg">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
Not accessible for Dealers
</p>
</div>
)}
{template.comingSoon && !isDealer && (
<div className="mt-3 flex items-start gap-2 p-2 bg-blue-50 border border-blue-200 rounded-lg">
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-blue-800 font-semibold">
Coming Soon
</p>
</div>
)}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-4"> <CardContent className="pt-0 space-y-4">
@ -219,12 +260,12 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
</Button> </Button>
<Button <Button
onClick={handleContinue} onClick={handleContinue}
disabled={!selectedTemplate} disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
size="lg" size="lg"
className={`gap-2 px-8 ${ className={`gap-2 px-8 ${
selectedTemplate selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
? 'bg-blue-600 hover:bg-blue-700' ? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-400' : 'bg-gray-400 cursor-not-allowed'
}`} }`}
> >
Continue with Template Continue with Template

View File

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

View File

@ -24,6 +24,8 @@ interface AddApproverModalProps {
requestTitle?: string; requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>; existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
currentLevels?: ApprovalLevelInfo[]; // Current approval levels currentLevels?: ApprovalLevelInfo[]; // Current approval levels
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
} }
export function AddApproverModal({ export function AddApproverModal({
@ -31,7 +33,9 @@ export function AddApproverModal({
onClose, onClose,
onConfirm, onConfirm,
existingParticipants = [], existingParticipants = [],
currentLevels = [] currentLevels = [],
maxApprovalLevels,
onPolicyViolation
}: AddApproverModalProps) { }: AddApproverModalProps) {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [tatHours, setTatHours] = useState<number>(24); const [tatHours, setTatHours] = useState<number>(24);
@ -140,6 +144,36 @@ export function AddApproverModal({
return; return;
} }
// Validate against maxApprovalLevels policy
// Calculate the new total levels after adding this approver
// If inserting at a level that already exists, levels shift down, so total stays same
// If inserting at a new level (beyond current), total increases
const currentMaxLevel = currentLevels.length > 0
? Math.max(...currentLevels.map(l => l.levelNumber), 0)
: 0;
const newTotalLevels = selectedLevel > currentMaxLevel
? selectedLevel // New level beyond current max
: currentMaxLevel + 1; // Existing level, shifts everything down, adds one more
if (maxApprovalLevels && newTotalLevels > maxApprovalLevels) {
if (onPolicyViolation) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `Adding an approver at level ${selectedLevel} would result in ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove an approver or contact your administrator.`,
currentValue: newTotalLevels,
maxValue: maxApprovalLevels
}]);
} else {
setValidationModal({
open: true,
type: 'error',
email: '',
message: `Cannot add approver. This would exceed the maximum allowed approval levels (${maxApprovalLevels}). Current request has ${currentMaxLevel} level(s).`
});
}
return;
}
// Check if user is already a participant // Check if user is already a participant
const existingParticipant = existingParticipants.find( const existingParticipant = existingParticipants.find(
p => (p.email || '').toLowerCase() === emailToAdd p => (p.email || '').toLowerCase() === emailToAdd
@ -394,6 +428,20 @@ export function AddApproverModal({
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down. Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
</p> </p>
{/* Max Approval Levels Note */}
{maxApprovalLevels && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2">
<p className="text-xs text-blue-800">
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
{currentLevels.length > 0 && (
<span className="ml-2">
({Math.max(...currentLevels.map(l => l.levelNumber), 0)}/{maxApprovalLevels})
</span>
)}
</p>
</div>
)}
{/* Current Levels Display */} {/* Current Levels Display */}
{currentLevels.length > 0 && ( {currentLevels.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">

View File

@ -6,7 +6,8 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
export interface SLAData { export interface SLAData {
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached'; status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
percentageUsed: number; percentageUsed?: number;
percent?: number; // Simplified format (alternative to percentageUsed)
elapsedText: string; elapsedText: string;
elapsedHours: number; elapsedHours: number;
remainingText: string; remainingText: string;
@ -27,8 +28,12 @@ export function SLAProgressBar({
isPaused = false, isPaused = false,
testId = 'sla-progress' testId = 'sla-progress'
}: SLAProgressBarProps) { }: SLAProgressBarProps) {
// Pure presentational component - no business logic
// If request is closed/approved/rejected or no SLA data, show status message // If request is closed/approved/rejected or no SLA data, show status message
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') { // Check if SLA has required fields (percentageUsed or at least some data)
const hasValidSLA = sla && (sla.percentageUsed !== undefined || sla.elapsedHours !== undefined);
if (!hasValidSLA || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
return ( return (
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}> <div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> : {requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
@ -47,7 +52,7 @@ export function SLAProgressBar({
// Use percentage-based colors to match approver SLA tracker // Use percentage-based colors to match approver SLA tracker
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached) // Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
// Grey: When paused (frozen state) // Grey: When paused (frozen state)
const percentageUsed = sla.percentageUsed || 0; const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : 0;
const rawStatus = sla.status || 'on_track'; const rawStatus = sla.status || 'on_track';
// Determine colors based on percentage (matching ApprovalStepCard logic) // Determine colors based on percentage (matching ApprovalStepCard logic)
@ -117,12 +122,12 @@ export function SLAProgressBar({
className={`text-xs ${colors.badge}`} className={`text-xs ${colors.badge}`}
data-testid={`${testId}-badge`} data-testid={`${testId}-badge`}
> >
{sla.percentageUsed || 0}% elapsed {isPaused && '(frozen)'} {percentageUsed}% elapsed {isPaused && '(frozen)'}
</Badge> </Badge>
</div> </div>
<Progress <Progress
value={sla.percentageUsed || 0} value={percentageUsed}
className="h-3 mb-2" className="h-3 mb-2"
indicatorClassName={colors.progress} indicatorClassName={colors.progress}
data-testid={`${testId}-bar`} data-testid={`${testId}-bar`}
@ -130,7 +135,7 @@ export function SLAProgressBar({
<div className="flex items-center justify-between text-xs mb-1"> <div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-600" data-testid={`${testId}-elapsed`}> <span className="text-gray-600" data-testid={`${testId}-elapsed`}>
{sla.elapsedText || formatHoursMinutes(sla.elapsedHours || 0)} elapsed {formatHoursMinutes(sla.elapsedHours || 0)} elapsed
</span> </span>
<span <span
className={`font-semibold ${ className={`font-semibold ${
@ -146,7 +151,7 @@ export function SLAProgressBar({
{sla.deadline && ( {sla.deadline && (
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}> <p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
Due: {formatDateDDMMYYYY(sla.deadline, true)} {sla.percentageUsed || 0}% elapsed Due: {formatDateDDMMYYYY(sla.deadline, true)} {percentageUsed}% elapsed
</p> </p>
)} )}

View File

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

View File

@ -0,0 +1,186 @@
"use client";
import * as React from "react";
import { format, parse, isValid } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { cn } from "./utils";
import { Button } from "./button";
import { Calendar } from "./calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "./popover";
export interface CustomDatePickerProps {
/**
* Selected date value as string in YYYY-MM-DD format (for form compatibility)
* or Date object
*/
value?: string | Date | null;
/**
* Callback when date changes. Returns date string in YYYY-MM-DD format
*/
onChange?: (date: string | null) => void;
/**
* Minimum selectable date as string (YYYY-MM-DD) or Date object
*/
minDate?: string | Date | null;
/**
* Maximum selectable date as string (YYYY-MM-DD) or Date object
*/
maxDate?: string | Date | null;
/**
* Placeholder text
*/
placeholderText?: string;
/**
* Whether the date picker is disabled
*/
disabled?: boolean;
/**
* Additional CSS classes
*/
className?: string;
/**
* CSS classes for the wrapper div
*/
wrapperClassName?: string;
/**
* Error state - shows red border
*/
error?: boolean;
/**
* Display format (default: "dd/MM/yyyy")
*/
displayFormat?: string;
/**
* ID for accessibility
*/
id?: string;
}
/**
* Reusable DatePicker component with consistent dd/MM/yyyy format and button trigger.
* Uses native Calendar component wrapped in a Popover.
*/
export function CustomDatePicker({
value,
onChange,
minDate,
maxDate,
placeholderText = "dd/mm/yyyy",
disabled = false,
className,
wrapperClassName,
error = false,
displayFormat = "dd/MM/yyyy",
id,
}: CustomDatePickerProps) {
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
// Convert input value to Date object for Calendar
const selectedDate = React.useMemo(() => {
if (!value) return undefined;
if (value instanceof Date) {
return isValid(value) ? value : undefined;
}
if (typeof value === "string") {
try {
const parsed = parse(value, "yyyy-MM-dd", new Date());
return isValid(parsed) ? parsed : undefined;
} catch (e) {
return undefined;
}
}
return undefined;
}, [value]);
// Convert minDate
const minDateObj = React.useMemo(() => {
if (!minDate) return undefined;
if (minDate instanceof Date) return isValid(minDate) ? minDate : undefined;
if (typeof minDate === "string") {
const parsed = parse(minDate, "yyyy-MM-dd", new Date());
return isValid(parsed) ? parsed : undefined;
}
return undefined;
}, [minDate]);
// Convert maxDate
const maxDateObj = React.useMemo(() => {
if (!maxDate) return undefined;
if (maxDate instanceof Date) return isValid(maxDate) ? maxDate : undefined;
if (typeof maxDate === "string") {
const parsed = parse(maxDate, "yyyy-MM-dd", new Date());
return isValid(parsed) ? parsed : undefined;
}
return undefined;
}, [maxDate]);
const handleSelect = (date: Date | undefined) => {
setIsPopoverOpen(false);
if (!onChange) return;
if (!date) {
onChange(null);
return;
}
// Return YYYY-MM-DD string
onChange(format(date, "yyyy-MM-dd"));
};
return (
<div className={cn("relative", wrapperClassName)}>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
id={id}
disabled={disabled}
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!selectedDate && "text-muted-foreground",
error && "border-destructive ring-destructive/20",
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? (
format(selectedDate, displayFormat)
) : (
<span>{placeholderText}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleSelect}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
);
}
export default CustomDatePicker;

View File

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

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi'; import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
import { sanitizeHTML } from '@/utils/sanitizer';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@ -82,6 +83,8 @@ interface WorkNoteChatProps {
isSpectator?: boolean; // Whether current user is a spectator (view-only) isSpectator?: boolean; // Whether current user is a spectator (view-only)
currentLevels?: any[]; // Current approval levels for add approver modal currentLevels?: any[]; // Current approval levels for add approver modal
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void; // Callback for policy violations
} }
// All data is now fetched from backend - no hardcoded mock data // All data is now fetched from backend - no hardcoded mock data
@ -107,9 +110,7 @@ const getStatusText = (status: string) => {
const formatMessage = (content: string) => { const formatMessage = (content: string) => {
// Enhanced mention highlighting - Blue color with extra bold font for high visibility // Enhanced mention highlighting - Blue color with extra bold font for high visibility
// Matches: @username or @FirstName LastName (only one space allowed for first name + last name) const formattedContent = content
// Pattern: @word or @word word (stops after second word)
return content
.replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => { .replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => {
const afterPos = offset + match.length; const afterPos = offset + match.length;
const afterChar = string[afterPos]; const afterChar = string[afterPos];
@ -122,6 +123,8 @@ const formatMessage = (content: string) => {
return match; return match;
}) })
.replace(/\n/g, '<br />'); .replace(/\n/g, '<br />');
return sanitizeHTML(formattedContent);
}; };
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
@ -142,7 +145,7 @@ const FileIcon = ({ type }: { type: string }) => {
return <Paperclip className={`${iconClass} text-gray-600`} />; return <Paperclip className={`${iconClass} text-gray-600`} />;
}; };
export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover }: WorkNoteChatProps) { export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover, maxApprovalLevels, onPolicyViolation }: WorkNoteChatProps) {
const routeParams = useParams<{ requestId: string }>(); const routeParams = useParams<{ requestId: string }>();
const effectiveRequestId = requestId || routeParams.requestId || ''; const effectiveRequestId = requestId || routeParams.requestId || '';
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@ -1272,8 +1275,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
<div key={msg.id} className={`flex gap-2 sm:gap-3 lg:gap-4 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}> <div key={msg.id} className={`flex gap-2 sm:gap-3 lg:gap-4 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
{!msg.isSystem && !isCurrentUser && ( {!msg.isSystem && !isCurrentUser && (
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0 ring-1 sm:ring-2 ring-white shadow-sm"> <Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0 ring-1 sm:ring-2 ring-white shadow-sm">
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${ <AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
msg.user.role === 'Initiator' ? 'bg-green-600' :
msg.user.role === 'Current User' ? 'bg-blue-500' : msg.user.role === 'Current User' ? 'bg-blue-500' :
msg.user.role === 'System' ? 'bg-gray-500' : msg.user.role === 'System' ? 'bg-gray-500' :
'bg-slate-600' 'bg-slate-600'
@ -1412,8 +1414,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
<button <button
key={index} key={index}
onClick={() => addReaction(msg.id, reaction.emoji)} onClick={() => addReaction(msg.id, reaction.emoji)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs sm:text-sm transition-colors flex-shrink-0 ${ className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs sm:text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
reaction.users.includes('You')
? 'bg-blue-100 text-blue-800 border border-blue-200' ? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`} }`}
@ -1562,8 +1563,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
className="w-full flex items-center gap-3 p-3 hover:bg-blue-50 rounded-lg text-left transition-colors border border-transparent hover:border-blue-200" className="w-full flex items-center gap-3 p-3 hover:bg-blue-50 rounded-lg text-left transition-colors border border-transparent hover:border-blue-200"
> >
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarFallback className={`text-white text-sm font-semibold ${ <AvatarFallback className={`text-white text-sm font-semibold ${participant.role === 'Initiator' ? 'bg-green-600' :
participant.role === 'Initiator' ? 'bg-green-600' :
participant.role === 'Approver' ? 'bg-purple-600' : participant.role === 'Approver' ? 'bg-purple-600' :
'bg-blue-500' 'bg-blue-500'
}`}> }`}>
@ -1711,8 +1711,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
<div key={index} className="flex items-center gap-3"> <div key={index} className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<Avatar className="h-9 w-9 sm:h-10 sm:w-10"> <Avatar className="h-9 w-9 sm:h-10 sm:w-10">
<AvatarFallback className={`text-white font-semibold text-sm ${ <AvatarFallback className={`text-white font-semibold text-sm ${participant.role === 'Initiator' ? 'bg-green-600' :
participant.role === 'Initiator' ? 'bg-green-600' :
isCurrentUser ? 'bg-blue-500' : 'bg-slate-600' isCurrentUser ? 'bg-blue-500' : 'bg-slate-600'
}`}> }`}>
{participant.avatar} {participant.avatar}
@ -1815,6 +1814,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
requestTitle={requestInfo.title} requestTitle={requestInfo.title}
existingParticipants={existingParticipants} existingParticipants={existingParticipants}
currentLevels={currentLevels} currentLevels={currentLevels}
maxApprovalLevels={maxApprovalLevels}
onPolicyViolation={onPolicyViolation}
/> />
)} )}

View File

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

View File

@ -1,794 +0,0 @@
import { useState, useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { DealerDocumentModal } from '@/components/modals/DealerDocumentModal';
import { InitiatorVerificationModal } from '@/components/modals/InitiatorVerificationModal';
import { Progress } from '@/components/ui/progress';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
import {
ArrowLeft,
Clock,
FileText,
MessageSquare,
CheckCircle,
XCircle,
Download,
Eye,
Flame,
Target,
TrendingUp,
RefreshCw,
Activity,
MapPin,
Mail,
Phone,
Building,
Receipt,
Upload,
UserPlus,
ClipboardList,
DollarSign,
Calendar
} from 'lucide-react';
interface ClaimManagementDetailProps {
requestId: string;
onBack?: () => void;
onOpenModal?: (modal: string) => void;
dynamicRequests?: any[];
}
// Utility functions
const getPriorityConfig = (priority: string) => {
switch (priority) {
case 'express':
case 'urgent':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: Flame
};
case 'standard':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Target
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: Target
};
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
icon: Clock
};
case 'in-review':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Eye
};
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
icon: CheckCircle
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: XCircle
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: Clock
};
}
};
const getSLAConfig = (progress: number) => {
if (progress >= 80) {
return {
bg: 'bg-red-50',
color: 'bg-red-500',
textColor: 'text-red-700'
};
} else if (progress >= 60) {
return {
bg: 'bg-orange-50',
color: 'bg-orange-500',
textColor: 'text-orange-700'
};
} else {
return {
bg: 'bg-green-50',
color: 'bg-green-500',
textColor: 'text-green-700'
};
}
};
const getStepIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'rejected':
return <XCircle className="w-5 h-5 text-red-600" />;
case 'pending':
case 'in-review':
return <Clock className="w-5 h-5 text-blue-600" />;
default:
return <Clock className="w-5 h-5 text-gray-400" />;
}
};
const getActionTypeIcon = (type: string) => {
switch (type) {
case 'approval':
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'rejection':
case 'rejected':
return <XCircle className="w-5 h-5 text-red-600" />;
case 'comment':
return <MessageSquare className="w-5 h-5 text-blue-600" />;
case 'status_change':
return <RefreshCw className="w-5 h-5 text-orange-600" />;
case 'assignment':
return <UserPlus className="w-5 h-5 text-purple-600" />;
case 'created':
return <FileText className="w-5 h-5 text-blue-600" />;
default:
return <Activity className="w-5 h-5 text-gray-600" />;
}
};
export function ClaimManagementDetail({
requestId,
onBack,
onOpenModal,
dynamicRequests = []
}: ClaimManagementDetailProps) {
const [activeTab, setActiveTab] = useState('overview');
const [dealerDocModal, setDealerDocModal] = useState(false);
const [initiatorVerificationModal, setInitiatorVerificationModal] = useState(false);
// Get claim from database or dynamic requests
const claim = useMemo(() => {
// First check static database
const staticClaim = CLAIM_MANAGEMENT_DATABASE[requestId];
if (staticClaim) return staticClaim;
// Then check dynamic requests
const dynamicClaim = dynamicRequests.find((req: any) => req.id === requestId);
if (dynamicClaim) return dynamicClaim;
return null;
}, [requestId, dynamicRequests]);
if (!claim) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Claim Not Found</h2>
<p className="text-gray-600 mb-4">The claim request you're looking for doesn't exist.</p>
<Button onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Go Back
</Button>
</div>
</div>
);
}
const priorityConfig = getPriorityConfig(claim.priority);
const statusConfig = getStatusConfig(claim.status);
const slaConfig = getSLAConfig(claim.slaProgress);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6">
{/* Header Section */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-4">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="mt-1"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center">
<Receipt className="w-6 h-6 text-purple-600" />
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-xl font-bold text-gray-900">{claim.id}</h1>
<Badge className={`${priorityConfig.color}`} variant="outline">
{claim.priority} priority
</Badge>
<Badge className={`${statusConfig.color}`} variant="outline">
<statusConfig.icon className="w-3 h-3 mr-1" />
{claim.status}
</Badge>
<Badge className="bg-purple-100 text-purple-800 border-purple-200" variant="outline">
<Receipt className="w-3 h-3 mr-1" />
Claim Management
</Badge>
</div>
<h2 className="text-lg font-semibold text-gray-900">{claim.title}</h2>
</div>
</div>
</div>
<div className="flex items-center gap-4">
{claim.amount && claim.amount !== 'TBD' && (
<div className="text-right">
<p className="text-sm text-gray-500">Claim Amount</p>
<p className="text-xl font-bold text-gray-900">{claim.amount}</p>
</div>
)}
<Button variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* SLA Progress */}
<div className={`${slaConfig.bg} rounded-lg border p-4 mt-4`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className={`h-4 w-4 ${slaConfig.textColor}`} />
<span className="text-sm font-medium text-gray-900">SLA Progress</span>
</div>
<span className={`text-sm font-semibold ${slaConfig.textColor}`}>
{claim.slaRemaining}
</span>
</div>
<Progress value={claim.slaProgress} className="h-2 mb-2" />
<p className="text-xs text-gray-600">
Due: {formatDateDDMMYYYY(claim.slaEndDate, true)} {claim.slaProgress}% elapsed
</p>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4 bg-gray-100 h-10 mb-6">
<TabsTrigger value="overview" className="flex items-center gap-2 text-xs sm:text-sm px-2">
<ClipboardList className="w-3 h-3 sm:w-4 sm:h-4" />
Overview
</TabsTrigger>
<TabsTrigger value="workflow" className="flex items-center gap-2 text-xs sm:text-sm px-2">
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4" />
Workflow (8-Steps)
</TabsTrigger>
<TabsTrigger value="documents" className="flex items-center gap-2 text-xs sm:text-sm px-2">
<FileText className="w-3 h-3 sm:w-4 sm:h-4" />
Documents
</TabsTrigger>
<TabsTrigger value="activity" className="flex items-center gap-2 text-xs sm:text-sm px-2">
<Activity className="w-3 h-3 sm:w-4 sm:h-4" />
Activity
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Content (2/3 width) */}
<div className="lg:col-span-2 space-y-6">
{/* Activity Information */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="w-5 h-5 text-blue-600" />
Activity Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Name</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.activityName || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Type</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.activityType || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Location</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
{claim.claimDetails?.location || 'N/A'}
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Date</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.activityDate || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Estimated Budget</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" />
{claim.claimDetails?.estimatedBudget || 'N/A'}
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Period</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{claim.claimDetails?.periodStart || 'N/A'} - {claim.claimDetails?.periodEnd || 'N/A'}
</p>
</div>
</div>
<div className="pt-4 border-t">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Description</label>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{claim.claimDetails?.requestDescription || claim.description || 'N/A'}
</p>
</div>
</CardContent>
</Card>
{/* Dealer Information */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Building className="w-5 h-5 text-purple-600" />
Dealer Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Dealer Code</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.dealerCode || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Dealer Name</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.dealerName || 'N/A'}</p>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Contact Information</label>
<div className="mt-2 space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4 text-gray-400" />
<span>{claim.claimDetails?.dealerEmail || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4 text-gray-400" />
<span>{claim.claimDetails?.dealerPhone || 'N/A'}</span>
</div>
{claim.claimDetails?.dealerAddress && (
<div className="flex items-start gap-2 text-sm text-gray-700">
<MapPin className="w-4 h-4 text-gray-400 mt-0.5" />
<span>{claim.claimDetails.dealerAddress}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Initiator Information */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Request Initiator</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-4">
<Avatar className="h-14 w-14 ring-2 ring-white shadow-md">
<AvatarFallback className="bg-gray-700 text-white font-semibold text-lg">
{claim.initiator?.avatar || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{claim.initiator?.name || 'N/A'}</h3>
<p className="text-sm text-gray-600">{claim.initiator?.role || 'N/A'}</p>
<p className="text-sm text-gray-500">{claim.initiator?.department || 'N/A'}</p>
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span>{claim.initiator?.email || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="w-4 h-4" />
<span>{claim.initiator?.phone || 'N/A'}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right Column - Quick Actions Sidebar (1/3 width) */}
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
className="w-full justify-start gap-2 border-gray-300"
onClick={() => onOpenModal?.('work-note')}
>
<MessageSquare className="w-4 h-4" />
Add Work Note
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2 border-gray-300"
onClick={() => onOpenModal?.('add-spectator')}
>
<Eye className="w-4 h-4" />
Add Spectator
</Button>
<div className="pt-4 space-y-2">
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
onClick={() => onOpenModal?.('approve')}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve Step
</Button>
<Button
variant="destructive"
className="w-full"
onClick={() => onOpenModal?.('reject')}
>
<XCircle className="w-4 h-4 mr-2" />
Reject Step
</Button>
</div>
</CardContent>
</Card>
{/* Spectators */}
{claim.spectators && claim.spectators.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Spectators</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{claim.spectators.map((spectator: any, index: number) => (
<div key={index} className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
{spectator.avatar}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{spectator.name}</p>
<p className="text-xs text-gray-500 truncate">{spectator.role}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</TabsContent>
{/* Workflow Tab - 8 Step Process */}
<TabsContent value="workflow">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
Claim Management Workflow
</CardTitle>
<CardDescription className="mt-2">
8-Step approval process for dealer claim management
</CardDescription>
</div>
<Badge variant="outline" className="font-medium">
Step {claim.currentStep} of {claim.totalSteps}
</Badge>
</div>
</CardHeader>
<CardContent>
{claim.approvalFlow && claim.approvalFlow.length > 0 ? (
<div className="space-y-4">
{claim.approvalFlow.map((step: any, index: number) => {
const isActive = step.status === 'pending' || step.status === 'in-review';
const isCompleted = step.status === 'approved';
const isRejected = step.status === 'rejected';
return (
<div
key={index}
className={`relative p-5 rounded-lg border-2 transition-all ${
isActive
? 'border-purple-500 bg-purple-50 shadow-md'
: isCompleted
? 'border-green-500 bg-green-50'
: isRejected
? 'border-red-500 bg-red-50'
: 'border-gray-200 bg-white'
}`}
>
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${
isActive ? 'bg-purple-100' :
isCompleted ? 'bg-green-100' :
isRejected ? 'bg-red-100' :
'bg-gray-100'
}`}>
{getStepIcon(step.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">Step {step.step}: {step.role}</h4>
<Badge variant="outline" className={
isActive ? 'bg-purple-100 text-purple-800 border-purple-200' :
isCompleted ? 'bg-green-100 text-green-800 border-green-200' :
isRejected ? 'bg-red-100 text-red-800 border-red-200' :
'bg-gray-100 text-gray-800 border-gray-200'
}>
{step.status}
</Badge>
</div>
<p className="text-sm text-gray-600">{step.approver}</p>
{step.description && (
<p className="text-sm text-gray-500 mt-2 italic">{step.description}</p>
)}
</div>
<div className="text-right">
{step.tatHours && (
<p className="text-xs text-gray-500">TAT: {step.tatHours}h</p>
)}
{step.elapsedHours !== undefined && step.elapsedHours > 0 && (
<p className="text-xs text-gray-600 font-medium">Elapsed: {step.elapsedHours}h</p>
)}
</div>
</div>
{step.comment && (
<div className="mt-3 p-3 bg-white rounded-lg border border-gray-200">
<p className="text-sm text-gray-700">{step.comment}</p>
</div>
)}
{step.timestamp && (
<p className="text-xs text-gray-500 mt-2">
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {step.timestamp}
</p>
)}
{/* Workflow-specific Action Buttons */}
{isActive && (
<div className="mt-4 flex gap-2">
{step.step === 1 && step.role === 'Dealer - Document Upload' && (
<Button
size="sm"
onClick={() => setDealerDocModal(true)}
className="bg-purple-600 hover:bg-purple-700"
>
<Upload className="w-4 h-4 mr-2" />
Upload Documents
</Button>
)}
{step.step === 2 && step.role === 'Initiator Evaluation' && (
<Button
size="sm"
onClick={() => onOpenModal?.('approve')}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve & Continue
</Button>
)}
{step.step === 4 && step.role === 'Department Lead Approval' && (
<Button
size="sm"
onClick={() => onOpenModal?.('approve')}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve & Lock Budget
</Button>
)}
{step.step === 5 && step.role === 'Dealer - Completion Documents' && (
<Button
size="sm"
onClick={() => setDealerDocModal(true)}
className="bg-purple-600 hover:bg-purple-700"
>
<Upload className="w-4 h-4 mr-2" />
Upload Completion Docs
</Button>
)}
{step.step === 6 && step.role === 'Initiator Verification' && (
<Button
size="sm"
onClick={() => setInitiatorVerificationModal(true)}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
Verify & Set Amount
</Button>
)}
{step.step === 8 && step.role.includes('Credit Note') && (
<Button
size="sm"
onClick={() => onOpenModal?.('approve')}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
Issue Credit Note
</Button>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8">No workflow steps defined</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-purple-600" />
Claim Documents
</CardTitle>
<Button size="sm">
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</div>
</CardHeader>
<CardContent>
{claim.documents && claim.documents.length > 0 ? (
<div className="space-y-3">
{claim.documents.map((doc: any, index: number) => (
<div
key={index}
className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<FileText className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="font-medium text-gray-900">{doc.name}</p>
<p className="text-xs text-gray-500">
{doc.size} Uploaded by {doc.uploadedBy} on {doc.uploadedAt}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm">
<Download className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8">No documents uploaded yet</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Activity Tab - Audit Trail */}
<TabsContent value="activity">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-orange-600" />
Claim Activity Timeline
</CardTitle>
<CardDescription>
Complete audit trail of all claim management activities
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{claim.auditTrail && claim.auditTrail.length > 0 ? claim.auditTrail.map((entry: any, index: number) => (
<div key={index} className="flex items-start gap-4 p-4 rounded-lg hover:bg-gray-50 transition-colors border border-gray-100">
<div className="mt-1">
{getActionTypeIcon(entry.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium text-gray-900">{entry.action}</p>
<p className="text-sm text-gray-600 mt-1">{entry.details}</p>
<p className="text-xs text-gray-500 mt-1">by {entry.user}</p>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">{entry.timestamp}</span>
</div>
</div>
</div>
)) : (
<div className="text-center py-12">
<Activity className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-sm text-gray-500">No activity recorded yet</p>
<p className="text-xs text-gray-400 mt-2">Actions and updates will appear here</p>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
{/* Claim Management Modals */}
{dealerDocModal && (
<DealerDocumentModal
isOpen={dealerDocModal}
onClose={() => setDealerDocModal(false)}
onSubmit={async (_documents) => {
toast.success('Documents Uploaded', {
description: 'Your documents have been submitted for review.',
});
setDealerDocModal(false);
}}
dealerName={claim.claimDetails?.dealerName || 'Dealer'}
activityName={claim.claimDetails?.activityName || claim.title}
/>
)}
{initiatorVerificationModal && (
<InitiatorVerificationModal
isOpen={initiatorVerificationModal}
onClose={() => setInitiatorVerificationModal(false)}
onSubmit={async (data) => {
toast.success('Verification Complete', {
description: `Amount set to ${data.approvedAmount}. E-invoice will be generated.`,
});
setInitiatorVerificationModal(false);
}}
activityName={claim.claimDetails?.activityName || claim.title}
requestedAmount={claim.claimDetails?.estimatedBudget || claim.amount || 'TBD'}
documents={claim.documents || []}
/>
)}
</div>
);
}

View File

@ -1 +0,0 @@
export { ClaimManagementDetail } from './ClaimManagementDetail';

View File

@ -1,650 +0,0 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { motion, AnimatePresence } from 'framer-motion';
import {
ArrowLeft,
ArrowRight,
Calendar as CalendarIcon,
Check,
Receipt,
Building,
MapPin,
Clock,
CheckCircle,
Info,
FileText,
} from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { getAllDealers, getDealerInfo, formatDealerAddress } from '@/utils/dealerDatabase';
interface ClaimManagementWizardProps {
onBack?: () => void;
onSubmit?: (claimData: any) => void;
}
const CLAIM_TYPES = [
'Marketing Activity',
'Promotional Event',
'Dealer Training',
'Infrastructure Development',
'Customer Experience Initiative',
'Service Campaign'
];
// Fetch dealers from database
const DEALERS = getAllDealers();
const STEP_NAMES = [
'Claim Details',
'Review & Submit'
];
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState({
activityName: '',
activityType: '',
dealerCode: '',
dealerName: '',
dealerEmail: '',
dealerPhone: '',
dealerAddress: '',
activityDate: undefined as Date | undefined,
location: '',
requestDescription: '',
periodStartDate: undefined as Date | undefined,
periodEndDate: undefined as Date | undefined,
estimatedBudget: ''
});
const totalSteps = STEP_NAMES.length;
const updateFormData = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const isStepValid = () => {
switch (currentStep) {
case 1:
return formData.activityName &&
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
case 2:
return true;
default:
return false;
}
};
const nextStep = () => {
if (currentStep < totalSteps && isStepValid()) {
setCurrentStep(currentStep + 1);
}
};
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleDealerChange = (dealerCode: string) => {
const dealer = getDealerInfo(dealerCode);
if (dealer) {
updateFormData('dealerCode', dealer.code);
updateFormData('dealerName', dealer.name);
updateFormData('dealerEmail', dealer.email);
updateFormData('dealerPhone', dealer.phone);
updateFormData('dealerAddress', formatDealerAddress(dealer));
}
};
const handleSubmit = () => {
const claimData = {
...formData,
templateType: 'claim-management',
submittedAt: new Date().toISOString(),
status: 'pending',
currentStep: 'initiator-review',
workflowSteps: [
{
step: 1,
name: 'Initiator Evaluation',
status: 'pending',
approver: 'Current User (Initiator)',
description: 'Review and confirm all claim details and documents'
},
{
step: 2,
name: 'IO Confirmation',
status: 'waiting',
approver: 'System',
description: 'Automatic IO generation upon initiator approval'
},
{
step: 3,
name: 'Department Lead Approval',
status: 'waiting',
approver: 'Department Lead',
description: 'Budget blocking and final approval'
},
{
step: 4,
name: 'Document Submission',
status: 'waiting',
approver: 'Dealer',
description: 'Dealer submits completion documents'
},
{
step: 5,
name: 'Document Verification',
status: 'waiting',
approver: 'Initiator',
description: 'Verify completion documents'
},
{
step: 6,
name: 'E-Invoice Generation',
status: 'waiting',
approver: 'System',
description: 'Auto-generate e-invoice based on approved amount'
},
{
step: 7,
name: 'Credit Note Issuance',
status: 'waiting',
approver: 'Finance',
description: 'Issue credit note to dealer'
}
]
};
toast.success('Claim Request Created', {
description: 'Your claim management request has been submitted successfully.'
});
if (onSubmit) {
onSubmit(claimData);
}
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
>
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Receipt className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Claim Details</h2>
<p className="text-gray-600">
Provide comprehensive information about your claim request
</p>
</div>
<div className="max-w-3xl mx-auto space-y-6">
{/* Activity Name and Type */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="activityName" className="text-base font-semibold">Activity Name *</Label>
<Input
id="activityName"
placeholder="e.g., Himalayan Adventure Fest 2024"
value={formData.activityName}
onChange={(e) => updateFormData('activityName', e.target.value)}
className="mt-2 h-12"
/>
</div>
<div>
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
<Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}>
<SelectTrigger className="mt-2 h-12">
<SelectValue placeholder="Select activity type" />
</SelectTrigger>
<SelectContent>
{CLAIM_TYPES.map((type) => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Dealer Selection */}
<div>
<Label htmlFor="dealer" className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
<Select value={formData.dealerCode} onValueChange={handleDealerChange}>
<SelectTrigger className="mt-2 h-12">
<SelectValue placeholder="Select dealer">
{formData.dealerCode && (
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{formData.dealerCode}</span>
<span className="text-gray-400"></span>
<span>{formData.dealerName}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{DEALERS.map((dealer) => (
<SelectItem key={dealer.code} value={dealer.code}>
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold">{dealer.code}</span>
<span className="text-gray-400"></span>
<span>{dealer.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{formData.dealerCode && (
<p className="text-sm text-gray-600 mt-2">
Selected: <span className="font-semibold">{formData.dealerName}</span> ({formData.dealerCode})
</p>
)}
</div>
{/* Date and Location */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-base font-semibold">Date *</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left mt-2 h-12"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.activityDate ? format(formData.activityDate, 'PPP') : 'Select date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.activityDate}
onSelect={(date) => updateFormData('activityDate', date)}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div>
<Label htmlFor="location" className="text-base font-semibold">Location *</Label>
<Input
id="location"
placeholder="e.g., Mumbai, Maharashtra"
value={formData.location}
onChange={(e) => updateFormData('location', e.target.value)}
className="mt-2 h-12"
/>
</div>
</div>
{/* Request Detail */}
<div>
<Label htmlFor="requestDescription" className="text-base font-semibold">Request in Detail - Brief Requirement *</Label>
<Textarea
id="requestDescription"
placeholder="Provide a detailed description of your claim requirement..."
value={formData.requestDescription}
onChange={(e) => updateFormData('requestDescription', e.target.value)}
className="mt-2 min-h-[120px]"
/>
<p className="text-xs text-gray-500 mt-1">
Include key details about the claim, objectives, and expected outcomes
</p>
</div>
{/* Period (Optional) */}
<div>
<div className="flex items-center gap-2 mb-3">
<Label className="text-base font-semibold">Period (If Any)</Label>
<Badge variant="secondary" className="text-xs">Optional</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-sm text-gray-600">Start Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left mt-2 h-12"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Start date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.periodStartDate}
onSelect={(date) => updateFormData('periodStartDate', date)}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-sm text-gray-600">End Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left mt-2 h-12"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.periodEndDate}
onSelect={(date) => updateFormData('periodEndDate', date)}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
{(formData.periodStartDate || formData.periodEndDate) && (
<p className="text-xs text-gray-600 mt-2">
{formData.periodStartDate && formData.periodEndDate
? `Period: ${format(formData.periodStartDate, 'MMM dd, yyyy')} - ${format(formData.periodEndDate, 'MMM dd, yyyy')}`
: 'Please select both start and end dates for the period'}
</p>
)}
</div>
</div>
</motion.div>
);
case 2:
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
>
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Submit</h2>
<p className="text-gray-600">
Review your claim details before submission
</p>
</div>
<div className="max-w-3xl mx-auto space-y-6">
{/* Activity Information */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-blue-50 to-indigo-50">
<CardTitle className="flex items-center gap-2">
<Receipt className="w-5 h-5 text-blue-600" />
Activity Information
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Activity Name</Label>
<p className="font-semibold text-gray-900 mt-1">{formData.activityName}</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Activity Type</Label>
<Badge variant="secondary" className="mt-1">{formData.activityType}</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Dealer Information */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-green-50 to-emerald-50">
<CardTitle className="flex items-center gap-2">
<Building className="w-5 h-5 text-green-600" />
Dealer Information
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Dealer Code</Label>
<p className="font-semibold text-gray-900 mt-1 font-mono">{formData.dealerCode}</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Dealer Name</Label>
<p className="font-semibold text-gray-900 mt-1">{formData.dealerName}</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Email</Label>
<p className="text-gray-900 mt-1">{formData.dealerEmail}</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Phone</Label>
<p className="text-gray-900 mt-1">{formData.dealerPhone}</p>
</div>
{formData.dealerAddress && (
<div className="col-span-2">
<Label className="text-xs text-gray-600 uppercase tracking-wider">Address</Label>
<p className="text-gray-900 mt-1">{formData.dealerAddress}</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Date & Location */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-purple-50 to-pink-50">
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-purple-600" />
Date & Location
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Date</Label>
<p className="font-semibold text-gray-900 mt-1">
{formData.activityDate ? format(formData.activityDate, 'PPP') : 'N/A'}
</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Location</Label>
<div className="flex items-center gap-2 mt-1">
<MapPin className="w-4 h-4 text-gray-500" />
<p className="font-semibold text-gray-900">{formData.location}</p>
</div>
</div>
{formData.estimatedBudget && (
<div className="col-span-2">
<Label className="text-xs text-gray-600 uppercase tracking-wider">Estimated Budget</Label>
<p className="text-xl font-bold text-blue-900 mt-1">{formData.estimatedBudget}</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Request Details */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-orange-50 to-amber-50">
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-orange-600" />
Request Details
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
<div className="mt-2 p-4 bg-gray-50 rounded-lg border">
<p className="text-gray-900 whitespace-pre-wrap">{formData.requestDescription}</p>
</div>
</div>
</CardContent>
</Card>
{/* Period (if provided) */}
{(formData.periodStartDate || formData.periodEndDate) && (
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-cyan-50 to-blue-50">
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5 text-cyan-600" />
Period
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Start Date</Label>
<p className="font-semibold text-gray-900 mt-1">
{formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Not specified'}
</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">End Date</Label>
<p className="font-semibold text-gray-900 mt-1">
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'Not specified'}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Confirmation Message */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<div className="flex items-start gap-3">
<Info className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-blue-900 mb-1">Ready to Submit</p>
<p className="text-sm text-blue-800">
Please review all the information above. Once submitted, your claim request will enter the approval workflow.
</p>
</div>
</div>
</div>
</div>
</motion.div>
);
default:
return null;
}
};
return (
<div className="w-full bg-gradient-to-br from-gray-50 to-gray-100 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-6 overflow-y-auto">
<div className="max-w-6xl mx-auto pb-8">
{/* Header */}
<div className="mb-6 sm:mb-8">
<Button
variant="ghost"
onClick={onBack}
className="mb-3 sm:mb-4 gap-2 text-sm sm:text-base"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Back to Templates</span>
<span className="sm:hidden">Back</span>
</Button>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
<div>
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900">New Claim Request</h1>
<p className="text-sm sm:text-base text-gray-600 mt-1">
Step {currentStep} of {totalSteps}: <span className="hidden sm:inline">{STEP_NAMES[currentStep - 1]}</span>
</p>
</div>
</div>
{/* Progress Bar */}
<div className="mt-4 sm:mt-6">
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
<div className="flex justify-between mt-2 px-1">
{STEP_NAMES.map((_name, index) => (
<span
key={index}
className={`text-xs sm:text-sm ${
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
}`}
>
{index + 1}
</span>
))}
</div>
</div>
</div>
{/* Step Content */}
<Card className="mb-6 sm:mb-8">
<CardContent className="p-4 sm:p-6 lg:p-8">
<AnimatePresence mode="wait">
{renderStepContent()}
</AnimatePresence>
</CardContent>
</Card>
{/* Navigation */}
<div className="flex flex-col sm:flex-row justify-between gap-3 sm:gap-0 pb-4 sm:pb-0">
<Button
variant="outline"
onClick={prevStep}
disabled={currentStep === 1}
className="gap-2 w-full sm:w-auto order-2 sm:order-1"
>
<ArrowLeft className="w-4 h-4" />
Previous
</Button>
{currentStep < totalSteps ? (
<Button
onClick={nextStep}
disabled={!isStepValid()}
className="gap-2 w-full sm:w-auto order-1 sm:order-2"
>
Next
<ArrowRight className="w-4 h-4" />
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!isStepValid()}
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto order-1 sm:order-2"
>
<Check className="w-4 h-4" />
Submit Claim Request
</Button>
)}
</div>
</div>
</div>
);
}

View File

@ -1 +0,0 @@
export { ClaimManagementWizard } from './ClaimManagementWizard';

View File

@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react'; import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
import { FormData } from '@/hooks/useCreateRequestForm'; import { FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
import { useMultiUserSearch } from '@/hooks/useUserSearch'; import { useMultiUserSearch } from '@/hooks/useUserSearch';
import { ensureUserExists } from '@/services/userApi'; import { ensureUserExists } from '@/services/userApi';
@ -15,6 +15,8 @@ interface ApprovalWorkflowStepProps {
formData: FormData; formData: FormData;
updateFormData: (field: keyof FormData, value: any) => void; updateFormData: (field: keyof FormData, value: any) => void;
onValidationError: (error: { type: string; email: string; message: string }) => void; onValidationError: (error: { type: string; email: string; message: string }) => void;
systemPolicy: SystemPolicy;
onPolicyViolation: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
} }
/** /**
@ -33,7 +35,9 @@ interface ApprovalWorkflowStepProps {
export function ApprovalWorkflowStep({ export function ApprovalWorkflowStep({
formData, formData,
updateFormData, updateFormData,
onValidationError onValidationError,
systemPolicy,
onPolicyViolation
}: ApprovalWorkflowStepProps) { }: ApprovalWorkflowStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch(); const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
@ -218,17 +222,29 @@ export function ApprovalWorkflowStep({
size="sm" size="sm"
onClick={() => { onClick={() => {
const currentCount = formData.approverCount || 1; const currentCount = formData.approverCount || 1;
const newCount = Math.min(10, currentCount + 1); const newCount = currentCount + 1;
// Validate against system policy
if (newCount > systemPolicy.maxApprovalLevels) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `Cannot add more than ${systemPolicy.maxApprovalLevels} approval levels. Please remove an approver level or contact your administrator.`,
currentValue: newCount,
maxValue: systemPolicy.maxApprovalLevels
}]);
return;
}
updateFormData('approverCount', newCount); updateFormData('approverCount', newCount);
}} }}
disabled={(formData.approverCount || 1) >= 10} disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
data-testid="approval-workflow-increase-count" data-testid="approval-workflow-increase-count"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
</div> </div>
<p className="text-sm text-gray-600 mt-2"> <p className="text-sm text-gray-600 mt-2">
Maximum 10 approvers allowed. Each approver will review sequentially. Maximum {systemPolicy.maxApprovalLevels} approver{systemPolicy.maxApprovalLevels !== 1 ? 's' : ''} allowed. Each approver will review sequentially.
</p> </p>
</div> </div>
</CardContent> </CardContent>

View File

@ -160,7 +160,7 @@ export function DocumentsStep({
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" /> <Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
Drag and drop files here, or click to browse click to browse
</p> </p>
<input <input
type="file" type="file"

View File

@ -3,11 +3,11 @@ import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Check, Clock, Users, Info, Flame, Target, TrendingUp, FolderOpen, ArrowLeft } from 'lucide-react'; import { Check, Clock, Users, Flame, Target, TrendingUp, FolderOpen, ArrowLeft, Info } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm'; import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
interface TemplateSelectionStepProps { interface TemplateSelectionStepProps {
templates: RequestTemplate[]; templates: RequestTemplate[];
@ -52,18 +52,18 @@ export function TemplateSelectionStep({
const displayTemplates = viewMode === 'main' const displayTemplates = viewMode === 'main'
? [ ? [
...templates, ...templates,
{ // {
id: 'admin-templates-category', // id: 'admin-templates-category',
name: 'Admin Templates', // name: 'Admin Templates',
description: 'Browse standardized request workflows created by your organization administrators', // description: 'Browse standardized request workflows created by your organization administrators',
category: 'Organization', // category: 'Organization',
icon: FolderOpen, // icon: FolderOpen,
estimatedTime: 'Variable', // estimatedTime: 'Variable',
commonApprovers: [], // commonApprovers: [],
suggestedSLA: 0, // suggestedSLA: 0,
priority: 'medium', // priority: 'medium',
fields: {} // fields: {}
} as any // } as any
] ]
: adminTemplates; : adminTemplates;
@ -111,7 +111,7 @@ export function TemplateSelectionStep({
const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
const isDisabled = isComingSoon; const isDisabled = isComingSoon;
const isCategoryCard = template.id === 'admin-templates-category'; const isCategoryCard = template.id === 'admin-templates-category';
const isCustomCard = template.id === 'custom'; // const isCustomCard = template.id === 'custom';
const isSelected = selectedTemplate?.id === template.id; const isSelected = selectedTemplate?.id === template.id;
return ( return (

View File

@ -6,7 +6,7 @@ export interface DocumentData {
documentId: string; documentId: string;
name: string; name: string;
fileType: string; fileType: string;
size: string; size?: string;
sizeBytes?: number; sizeBytes?: number;
uploadedBy?: string; uploadedBy?: string;
uploadedAt: string; uploadedAt: string;
@ -48,7 +48,9 @@ export function DocumentCard({
{document.name} {document.name}
</p> </p>
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}> <p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
{document.size} Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)} {document.size && <span>{document.size} </span>}
{document.uploadedBy && <span>Uploaded by {document.uploadedBy} on </span>}
{formatDateTime(document.uploadedAt)}
</p> </p>
</div> </div>
</div> </div>

View File

@ -9,6 +9,7 @@ import { createContext, useContext, useEffect, useState, ReactNode, useRef } fro
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react'; import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
import { TokenManager, isTokenExpired } from '../utils/tokenManager'; import { TokenManager, isTokenExpired } from '../utils/tokenManager';
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi'; import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
import { tanflowLogout } from '../services/tanflowAuth';
interface User { interface User {
userId?: string; userId?: string;
@ -100,18 +101,28 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// PRIORITY 2: Check if URL has logout parameter (from redirect) // PRIORITY 2: Check if URL has logout parameter (from redirect)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('logout') || urlParams.has('okta_logged_out')) { if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
console.log('🚪 Logout parameter detected in URL, clearing all tokens');
TokenManager.clearAll(); TokenManager.clearAll();
// Clear auth provider flag and logout-related flags
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__');
sessionStorage.removeItem('tanflow_logged_out');
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); // Don't clear sessionStorage completely - we might need logout flags
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setIsLoading(false); setIsLoading(false);
// Clean URL but preserve okta_logged_out flag if it exists (for prompt=login) // Clean URL but preserve logout flags if they exist (for prompt=login)
const cleanParams = new URLSearchParams(); const cleanParams = new URLSearchParams();
if (urlParams.has('okta_logged_out')) { if (urlParams.has('okta_logged_out')) {
cleanParams.set('okta_logged_out', 'true'); cleanParams.set('okta_logged_out', 'true');
} }
if (urlParams.has('tanflow_logged_out')) {
cleanParams.set('tanflow_logged_out', 'true');
}
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/'; const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
window.history.replaceState({}, document.title, newUrl); window.history.replaceState({}, document.title, newUrl);
return; return;
@ -120,7 +131,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first // PRIORITY 3: Skip auth check if on callback page - let callback handler process first
// This is critical for production mode where we need to exchange code for tokens // This is critical for production mode where we need to exchange code for tokens
// before we can verify session with server // before we can verify session with server
if (window.location.pathname === '/login/callback') { if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') {
// Don't check auth status here - let the callback handler do its job // Don't check auth status here - let the callback handler do its job
// The callback handler will set isAuthenticated after successful token exchange // The callback handler will set isAuthenticated after successful token exchange
return; return;
@ -208,24 +219,57 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} }
const handleCallback = async () => { const handleCallback = async () => {
const urlParams = new URLSearchParams(window.location.search);
// Check if this is a logout redirect (from Tanflow post-logout redirect)
// If it has logout parameters but no code, it's a logout redirect, not a login callback
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
// This is a logout redirect, not a login callback
// Redirect to home page - the mount useEffect will handle logout cleanup
console.log('🚪 Logout redirect detected in callback, redirecting to home');
// Extract the logout flags from current URL
const logoutFlags = new URLSearchParams();
if (urlParams.has('tanflow_logged_out')) logoutFlags.set('tanflow_logged_out', 'true');
if (urlParams.has('okta_logged_out')) logoutFlags.set('okta_logged_out', 'true');
if (urlParams.has('logout')) logoutFlags.set('logout', urlParams.get('logout') || Date.now().toString());
const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now();
window.location.replace(redirectUrl);
return;
}
// Mark as processed immediately to prevent duplicate calls // Mark as processed immediately to prevent duplicate calls
callbackProcessedRef.current = true; callbackProcessedRef.current = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code'); const code = urlParams.get('code');
const errorParam = urlParams.get('error'); const errorParam = urlParams.get('error');
// Clean URL immediately to prevent re-running on re-renders // Clean URL immediately to prevent re-running on re-renders
window.history.replaceState({}, document.title, '/login/callback'); window.history.replaceState({}, document.title, '/login/callback');
// Detect provider from sessionStorage
const authProvider = sessionStorage.getItem('auth_provider');
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
if (authProvider === 'tanflow') {
// Clear the provider flag and let TanflowCallback handle it
// Reset ref so TanflowCallback can process
callbackProcessedRef.current = false;
return;
}
// Handle OKTA callback (default)
if (errorParam) { if (errorParam) {
setError(new Error(`Authentication error: ${errorParam}`)); setError(new Error(`Authentication error: ${errorParam}`));
setIsLoading(false); setIsLoading(false);
// Clear provider flag
sessionStorage.removeItem('auth_provider');
return; return;
} }
if (!code) { if (!code) {
setIsLoading(false); setIsLoading(false);
// Clear provider flag
sessionStorage.removeItem('auth_provider');
return; return;
} }
@ -245,6 +289,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsAuthenticated(true); setIsAuthenticated(true);
setError(null); setError(null);
// Clear provider flag after successful authentication
sessionStorage.removeItem('auth_provider');
// Clean URL after success // Clean URL after success
window.history.replaceState({}, document.title, '/'); window.history.replaceState({}, document.title, '/');
} catch (err: any) { } catch (err: any) {
@ -252,6 +299,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setError(err); setError(err);
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
// Clear provider flag on error
sessionStorage.removeItem('auth_provider');
// Reset ref on error so user can retry if needed // Reset ref on error so user can retry if needed
callbackProcessedRef.current = false; callbackProcessedRef.current = false;
} finally { } finally {
@ -412,9 +461,12 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const scope = 'openid profile email'; const scope = 'openid profile email';
const state = Math.random().toString(36).substring(7); const state = Math.random().toString(36).substring(7);
// Store provider type to identify OKTA callback
sessionStorage.setItem('auth_provider', 'okta');
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication // Check if we're coming from a logout - if so, add prompt=login to force re-authentication
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out'); const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` + let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
`client_id=${clientId}&` + `client_id=${clientId}&` +
@ -439,9 +491,14 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const logout = async () => { const logout = async () => {
try { try {
// CRITICAL: Get id_token from TokenManager before clearing anything // CRITICAL: Get id_token from TokenManager before clearing anything
// Okta logout endpoint works better with id_token_hint to properly end the session // Needed for both Okta and Tanflow logout endpoints
// Note: Currently not used but kept for future Okta integration const idToken = TokenManager.getIdToken();
void TokenManager.getIdToken();
// Detect which provider was used for login (check sessionStorage or user data)
// If auth_provider is set, use it; otherwise check if we have Tanflow id_token pattern
const authProvider = sessionStorage.getItem('auth_provider') ||
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
'okta'; // Default to OKTA if unknown
// Set logout flag to prevent auto-authentication after redirect // Set logout flag to prevent auto-authentication after redirect
// This must be set BEFORE clearing storage so it survives // This must be set BEFORE clearing storage so it survives
@ -459,29 +516,58 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies // IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
try { try {
await logoutApi(); await logoutApi();
console.log('🚪 Backend logout API called successfully');
} catch (err) { } catch (err) {
console.error('🚪 Logout API error:', err); console.error('🚪 Logout API error:', err);
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared'); console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
// Continue with logout even if API call fails // Continue with logout even if API call fails
} }
// Clear all authentication data EXCEPT the logout flags and id_token (we need it for Okta logout) // Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens
// Clear tokens but preserve logout flags
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__'); const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__'); const forceLogout = sessionStorage.getItem('__force_logout__');
const storedAuthProvider = sessionStorage.getItem('auth_provider');
// Use TokenManager.clearAll() but then restore logout flags // Clear all tokens EXCEPT id_token (we need it for provider logout)
// Note: We'll clear id_token after provider logout
// Clear tokens (but we'll restore id_token if needed)
TokenManager.clearAll(); TokenManager.clearAll();
// Restore logout flags immediately after clearAll // Restore logout flags and id_token immediately after clearAll
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress); if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout); if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
if (idToken) {
TokenManager.setIdToken(idToken); // Restore id_token for provider logout
}
if (storedAuthProvider) {
sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout
}
// Small delay to ensure sessionStorage is written before redirect // Small delay to ensure sessionStorage is written before redirect
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
// Redirect directly to login page with flags // Handle provider-specific logout
if (authProvider === 'tanflow' && idToken) {
console.log('🚪 Initiating Tanflow logout...');
// Tanflow logout - redirect to Tanflow logout endpoint
// This will clear Tanflow session and redirect back to our app
try {
tanflowLogout(idToken);
// tanflowLogout will redirect, so we don't need to do anything else here
return;
} catch (tanflowLogoutError) {
console.error('🚪 Tanflow logout error:', tanflowLogoutError);
// Fall through to default logout flow
}
}
// OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
console.log('🚪 Using OKTA logout flow or fallback');
sessionStorage.removeItem('auth_provider');
// Clear id_token now since we're not using provider logout
if (idToken) {
TokenManager.clearAll(); // Clear id_token too
}
// The okta_logged_out flag will trigger prompt=login in the login() function // The okta_logged_out flag will trigger prompt=login in the login() function
// This forces re-authentication even if Okta session still exists // This forces re-authentication even if Okta session still exists
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`; const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;

View File

@ -0,0 +1,172 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, Flame, Target, CheckCircle, XCircle, X } from 'lucide-react';
interface ClosedRequestsFiltersProps {
searchTerm: string;
priorityFilter: string;
statusFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc';
activeFiltersCount: number;
onSearchChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onStatusChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void;
onClearFilters: () => void;
}
/**
* Standard Closed Requests Filters Component
*
* Used for regular users (non-dealers).
* Includes: Search, Priority, Status (Closure Type), Template Type, and Sort filters.
*/
export function StandardClosedRequestsFilters({
searchTerm,
priorityFilter,
statusFilter,
// templateTypeFilter,
sortBy,
sortOrder,
activeFiltersCount,
onSearchChange,
onPriorityChange,
onStatusChange,
// onTemplateTypeChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
}: ClosedRequestsFiltersProps) {
return (
<Card className="shadow-lg border-0" data-testid="closed-requests-filters">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
data-testid="closed-requests-clear-filters"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white transition-colors"
data-testid="closed-requests-search"
/>
</div>
<Select value={priorityFilter} onValueChange={onPriorityChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-priority-filter">
<SelectValue placeholder="All Priorities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">
<div className="flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-600" />
<span>Express</span>
</div>
</SelectItem>
<SelectItem value="standard">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<span>Standard</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-status-filter">
<SelectValue placeholder="Closure Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Closures</SelectItem>
<SelectItem value="approved">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Closed After Approval</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span>Closed After Rejection</span>
</div>
</SelectItem>
</SelectContent>
</Select>
{/*
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select> */}
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={onSortOrderChange}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
data-testid="closed-requests-sort-order"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,161 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X, Flame, Target } from 'lucide-react';
interface RequestsFiltersProps {
searchTerm: string;
statusFilter: string;
priorityFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority' | 'sla';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusFilterChange: (value: string) => void;
onPriorityFilterChange: (value: string) => void;
onTemplateTypeFilterChange: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
onSortOrderChange: (value: 'asc' | 'desc') => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Standard Requests Filters Component
*
* Used for regular users (non-dealers).
* Includes: Search, Status, Priority, Template Type, and Sort filters.
*/
export function StandardRequestsFilters({
searchTerm,
statusFilter,
priorityFilter,
// templateTypeFilter,
sortBy,
sortOrder,
onSearchChange,
onStatusFilterChange,
onPriorityFilterChange,
// onTemplateTypeFilterChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
}: RequestsFiltersProps) {
return (
<Card className="shadow-lg border-0">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Standard filters - Search, Status, Priority, Template Type, and Sort */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
/>
</div>
<Select value={priorityFilter} onValueChange={onPriorityFilterChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Priorities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">
<div className="flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-600" />
<span>Express</span>
</div>
</SelectItem>
<SelectItem value="standard">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<span>Standard</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending">Pending (In Approval)</SelectItem>
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
</SelectContent>
</Select>
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select> */}
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,457 @@
/**
* Standard User All Requests Filters Component
*
* Full filters for regular users (non-dealers).
* Includes: Search, Status, Priority, Template Type, Department, SLA Compliance,
* Initiator, Approver, and Date Range filters.
*/
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import type { DateRange } from '@/services/dashboard.service';
import { CustomDatePicker } from '@/components/ui/date-picker';
interface StandardUserAllRequestsFiltersProps {
// Filters
searchTerm: string;
statusFilter: string;
priorityFilter: string;
templateTypeFilter: string;
departmentFilter: string;
slaComplianceFilter: string;
initiatorFilter: string;
approverFilter: string;
approverFilterType: 'current' | 'any';
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
// Departments
departments: string[];
loadingDepartments: boolean;
// State for user search
initiatorSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
approverSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
// Actions
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
onDepartmentChange: (value: string) => void;
onSlaComplianceChange: (value: string) => void;
onInitiatorChange?: (value: string) => void;
onApproverChange?: (value: string) => void;
onApproverTypeChange?: (value: 'current' | 'any') => void;
onDateRangeChange: (value: DateRange) => void;
onCustomStartDateChange?: (date: Date | undefined) => void;
onCustomEndDateChange?: (date: Date | undefined) => void;
onShowCustomDatePickerChange?: (show: boolean) => void;
onApplyCustomDate?: () => void;
onClearFilters: () => void;
// Computed
hasActiveFilters: boolean;
}
export function StandardUserAllRequestsFilters({
searchTerm,
statusFilter,
priorityFilter,
// templateTypeFilter,
departmentFilter,
slaComplianceFilter,
initiatorFilter: _initiatorFilter,
approverFilter,
approverFilterType,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
departments,
loadingDepartments,
initiatorSearch,
approverSearch,
onSearchChange,
onStatusChange,
onPriorityChange,
// onTemplateTypeChange,
onDepartmentChange,
onSlaComplianceChange,
onInitiatorChange: _onInitiatorChange,
onApproverChange: _onApproverChange,
onApproverTypeChange,
onDateRangeChange,
onCustomStartDateChange,
onCustomEndDateChange,
onShowCustomDatePickerChange,
onApplyCustomDate,
onClearFilters,
hasActiveFilters,
}: StandardUserAllRequestsFiltersProps) {
return (
<Card className="border-gray-200 shadow-md" data-testid="user-all-requests-filters">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Advanced Filters</h3>
{hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
<RefreshCw className="w-4 h-4" />
Clear All
</Button>
)}
</div>
<Separator />
{/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative md:col-span-3 lg:col-span-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 h-10"
data-testid="search-input"
/>
</div>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={onPriorityChange}>
<SelectTrigger className="h-10" data-testid="priority-filter">
<SelectValue placeholder="All Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
</SelectContent>
</Select>
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select> */}
<Select
value={departmentFilter}
onValueChange={onDepartmentChange}
disabled={loadingDepartments || departments.length === 0}
>
<SelectTrigger className="h-10" data-testid="department-filter">
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={slaComplianceFilter} onValueChange={onSlaComplianceChange}>
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
<SelectValue placeholder="All SLA Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All SLA Status</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="on-track">On Track</SelectItem>
<SelectItem value="approaching">Approaching</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="breached">Breached</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
if (initiatorSearch.searchResults.length > 0) {
initiatorSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
className="h-10"
data-testid="initiator-search-input"
/>
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{approverFilter !== 'all' && onApproverTypeChange && (
<Select
value={approverFilterType}
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {
if (approverSearch.searchResults.length > 0) {
approverSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
className="h-10"
data-testid="approver-search-input"
/>
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="last7days">Last 7 Days</SelectItem>
<SelectItem value="last30days">Last 30 Days</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<CustomDatePicker
value={customStartDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomStartDateChange?.(date);
if (customEndDate && date > customEndDate) {
onCustomEndDateChange?.(date);
}
} else {
onCustomStartDateChange?.(undefined);
}
}}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<CustomDatePicker
value={customEndDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomEndDateChange?.(date);
if (customStartDate && date < customStartDate) {
onCustomStartDateChange?.(date);
}
} else {
onCustomEndDateChange?.(undefined);
}
}}
minDate={customStartDate || undefined}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApplyCustomDate}
disabled={!customStartDate || !customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply Range
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
onShowCustomDatePickerChange?.(false);
onCustomStartDateChange?.(undefined);
onCustomEndDateChange?.(undefined);
onDateRangeChange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,9 @@
/**
* Custom Request Creation Component
*
* This component handles the creation of custom requests.
* Located in: src/custom/components/request-creation/
*/
// Re-export the original component
export { CreateRequest } from '@/pages/CreateRequest/CreateRequest';

View File

@ -0,0 +1,9 @@
/**
* Custom Request Overview Tab
*
* This component is specific to Custom requests.
* Located in: src/custom/components/request-detail/
*/
// Re-export the original component
export { OverviewTab } from '@/pages/RequestDetail/components/tabs/OverviewTab';

View File

@ -0,0 +1,9 @@
/**
* Custom Request Workflow Tab
*
* This component is specific to Custom requests.
* Located in: src/custom/components/request-detail/
*/
// Re-export the original component
export { WorkflowTab } from '@/pages/RequestDetail/components/tabs/WorkflowTab';

32
src/custom/index.ts Normal file
View File

@ -0,0 +1,32 @@
/**
* Custom Request Flow
*
* This module exports all components, hooks, utilities, and types
* specific to Custom requests. This allows for complete segregation
* of custom request functionality.
*
* LOCATION: src/custom/
*
* To remove Custom flow completely:
* 1. Delete this entire folder: src/custom/
* 2. Remove from src/flows.ts registry
* 3. Done! All custom request code is removed.
*/
// Request Detail Components
export { OverviewTab as CustomOverviewTab } from './components/request-detail/OverviewTab';
export { WorkflowTab as CustomWorkflowTab } from './components/request-detail/WorkflowTab';
// Request Creation Components
export { CreateRequest as CustomCreateRequest } from './components/request-creation/CreateRequest';
// Request Detail Screen (Complete standalone screen)
export { CustomRequestDetail } from './pages/RequestDetail';
// Filters
export { StandardRequestsFilters } from './components/RequestsFilters';
export { StandardClosedRequestsFilters } from './components/ClosedRequestsFilters';
export { StandardUserAllRequestsFilters } from './components/UserAllRequestsFilters';
// Re-export types
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';

View File

@ -0,0 +1,713 @@
/**
* Custom Request Detail Screen
*
* Standalone, dedicated request detail screen for Custom requests.
* This is a complete module that uses custom request specific components.
*
* LOCATION: src/custom/pages/RequestDetail.tsx
*
* IMPORTANT: This entire file and all its dependencies are in src/custom/ folder.
* Deleting src/custom/ folder removes ALL custom request related code.
*/
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ClipboardList,
TrendingUp,
FileText,
Activity,
MessageSquare,
AlertTriangle,
FileCheck,
ShieldX,
RefreshCw,
ArrowLeft,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
// Context and hooks
import { useAuth } from '@/contexts/AuthContext';
import { useRequestDetails } from '@/hooks/useRequestDetails';
import { useRequestSocket } from '@/hooks/useRequestSocket';
import { useDocumentUpload } from '@/hooks/useDocumentUpload';
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
import { useModalManager } from '@/hooks/useModalManager';
import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
// Custom Request Components (import from index to get properly aliased exports)
import { CustomOverviewTab, CustomWorkflowTab } from '../index';
// Shared Components (from src/shared/)
import { SharedComponents } from '@/shared/components';
const { DocumentsTab, ActivityTab, WorkNotesTab, SummaryTab, RequestDetailHeader, QuickActionsSidebar, RequestDetailModals } = SharedComponents;
// Other components
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi';
import { toast } from 'sonner';
import { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
import { PauseModal } from '@/components/workflow/PauseModal';
import { ResumeModal } from '@/components/workflow/ResumeModal';
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
/**
* Error Boundary Component
*/
class RequestDetailErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: Error | null }> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Custom RequestDetail Error:', error, errorInfo);
}
override render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold mb-2">Error Loading Request</h2>
<p className="text-gray-600 mb-4">{this.state.error?.message || 'An unexpected error occurred'}</p>
<Button onClick={() => window.location.reload()} className="mr-2">
Reload Page
</Button>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
/**
* Custom RequestDetailInner Component
*/
function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests = [] }: RequestDetailProps) {
const params = useParams<{ requestId: string }>();
const requestIdentifier = params.requestId || propRequestId || '';
const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'overview';
const [activeTab, setActiveTab] = useState(initialTab);
const [showShareSummaryModal, setShowShareSummaryModal] = useState(false);
const [summaryId, setSummaryId] = useState<string | null>(null);
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
const [loadingSummary, setLoadingSummary] = useState(false);
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false);
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
const { user } = useAuth();
// Custom hooks
const {
request,
apiRequest,
loading: requestLoading,
refreshing,
refreshDetails,
currentApprovalLevel,
isSpectator,
isInitiator,
existingParticipants,
accessDenied,
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
const {
mergedMessages,
unreadWorkNotes,
workNoteAttachments,
setWorkNoteAttachments,
} = useRequestSocket(requestIdentifier, apiRequest, activeTab, user);
const {
uploadingDocument,
triggerFileInput,
previewDocument,
setPreviewDocument,
documentPolicy,
documentError,
setDocumentError,
} = useDocumentUpload(apiRequest, refreshDetails);
const {
showApproveModal,
setShowApproveModal,
showRejectModal,
setShowRejectModal,
showAddApproverModal,
setShowAddApproverModal,
showAddSpectatorModal,
setShowAddSpectatorModal,
showSkipApproverModal,
setShowSkipApproverModal,
showActionStatusModal,
setShowActionStatusModal,
skipApproverData,
setSkipApproverData,
actionStatus,
setActionStatus,
handleApproveConfirm,
handleRejectConfirm,
handleAddApprover,
handleSkipApprover,
handleAddSpectator,
} = useModalManager(requestIdentifier, currentApprovalLevel, refreshDetails);
const {
conclusionRemark,
setConclusionRemark,
conclusionLoading,
conclusionSubmitting,
aiGenerated,
handleGenerateConclusion,
handleFinalizeConclusion,
generationAttempts,
generationFailed,
maxAttemptsReached,
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
// Load system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
}
};
loadSystemPolicy();
}, []);
// Auto-switch tab when URL query parameter changes
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const tabParam = urlParams.get('tab');
if (tabParam) {
setActiveTab(tabParam);
}
}, [requestIdentifier]);
const handleRefresh = () => {
refreshDetails();
};
// Pause handlers
const handlePause = () => {
setShowPauseModal(true);
};
const handleResume = () => {
setShowResumeModal(true);
};
const handleResumeSuccess = async () => {
await refreshDetails();
};
const handleRetrigger = () => {
setShowRetriggerModal(true);
};
const handlePauseSuccess = async () => {
await refreshDetails();
};
const handleRetriggerSuccess = async () => {
await refreshDetails();
};
const handleShareSummary = async () => {
if (!apiRequest?.requestId) {
toast.error('Request ID not found');
return;
}
if (!summaryId) {
toast.error('Summary not available. Please ensure the request is closed and the summary has been generated.');
return;
}
setShowShareSummaryModal(true);
};
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
const isClosed = request?.status === 'closed';
// Fetch summary details if request is closed
useEffect(() => {
const fetchSummaryDetails = async () => {
if (!isClosed || !apiRequest?.requestId) {
setSummaryDetails(null);
setSummaryId(null);
return;
}
try {
setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) {
setSummaryId(summary.summaryId);
try {
const details = await getSummaryDetails(summary.summaryId);
setSummaryDetails(details);
} catch (error: any) {
console.error('Failed to fetch summary details:', error);
setSummaryDetails(null);
setSummaryId(null);
}
} else {
setSummaryDetails(null);
setSummaryId(null);
}
} catch (error: any) {
setSummaryDetails(null);
setSummaryId(null);
} finally {
setLoadingSummary(false);
}
};
fetchSummaryDetails();
}, [isClosed, apiRequest?.requestId]);
// Get current levels for WorkNotesTab
const currentLevels = (request?.approvalFlow || [])
.filter((flow: any) => flow && typeof flow.step === 'number')
.map((flow: any) => ({
levelNumber: flow.step || 0,
approverName: flow.approver || 'Unknown',
status: flow.status || 'pending',
tatHours: flow.tatHours || 24,
}));
// Loading state
if (requestLoading && !request && !apiRequest) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state">
<div className="text-center">
<RefreshCw className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
<p className="text-gray-600">Loading custom request details...</p>
</div>
</div>
);
}
// Access Denied state
if (accessDenied?.denied) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="access-denied-state">
<div className="max-w-lg w-full bg-white rounded-2xl shadow-xl p-8 text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<ShieldX className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Access Denied</h2>
<p className="text-gray-600 mb-6 leading-relaxed">
{accessDenied.message}
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
Go to Dashboard
</Button>
</div>
</div>
</div>
);
}
// Not Found state
if (!request) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="not-found-state">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<FileText className="w-10 h-10 text-gray-400" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Custom Request Not Found</h2>
<p className="text-gray-600 mb-6">
The custom request you're looking for doesn't exist or may have been deleted.
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
Go to Dashboard
</Button>
</div>
</div>
</div>
);
}
return (
<>
<div className="min-h-screen bg-gray-50" data-testid="custom-request-detail-page">
<div className="max-w-7xl mx-auto">
{/* Header Section */}
<RequestDetailHeader
request={request}
refreshing={refreshing}
onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh}
onShareSummary={handleShareSummary}
isInitiator={isInitiator}
// Custom module: Business logic for preparing SLA data
slaData={request?.summary?.sla || request?.sla || null}
isPaused={request?.pauseInfo?.isPaused || false}
/>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="custom-request-detail-tabs">
<div className="mb-4 sm:mb-6">
<TabsList className="grid grid-cols-3 sm:grid-cols-6 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
<TabsTrigger
value="overview"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-overview"
>
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Overview</span>
</TabsTrigger>
{isClosed && summaryDetails && (
<TabsTrigger
value="summary"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-summary"
>
<FileCheck className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Summary</span>
</TabsTrigger>
)}
<TabsTrigger
value="workflow"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-workflow"
>
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Workflow</span>
</TabsTrigger>
<TabsTrigger
value="documents"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-documents"
>
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Docs</span>
</TabsTrigger>
<TabsTrigger
value="activity"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 col-span-1 sm:col-span-1"
data-testid="tab-activity"
>
<Activity className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Activity</span>
</TabsTrigger>
<TabsTrigger
value="worknotes"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 relative col-span-2 sm:col-span-1"
data-testid="tab-worknotes"
>
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Work Notes</span>
{unreadWorkNotes > 0 && (
<Badge
className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-[10px] flex items-center justify-center p-0"
data-testid="worknotes-unread-badge"
>
{unreadWorkNotes > 9 ? '9+' : unreadWorkNotes}
</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
{/* Main Layout */}
<div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
{/* Left Column: Tab content */}
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
<CustomOverviewTab
request={request}
isInitiator={isInitiator}
needsClosure={needsClosure}
conclusionRemark={conclusionRemark}
setConclusionRemark={setConclusionRemark}
conclusionLoading={conclusionLoading}
conclusionSubmitting={conclusionSubmitting}
aiGenerated={aiGenerated}
handleGenerateConclusion={handleGenerateConclusion}
handleFinalizeConclusion={handleFinalizeConclusion}
onPause={handlePause}
onResume={handleResume}
onRetrigger={handleRetrigger}
currentUserIsApprover={!!currentApprovalLevel}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
/>
</TabsContent>
{isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab
summary={summaryDetails}
loading={loadingSummary}
onShare={handleShareSummary}
isInitiator={isInitiator}
/>
</TabsContent>
)}
<TabsContent value="workflow" className="mt-0">
<CustomWorkflowTab
request={request}
user={user}
isInitiator={isInitiator}
onSkipApprover={(data) => {
if (!data.levelId) {
alert('Level ID not available');
return;
}
setSkipApproverData(data);
setShowSkipApproverModal(true);
}}
onRefresh={refreshDetails}
/>
</TabsContent>
<TabsContent value="documents" className="mt-0">
<DocumentsTab
request={request}
workNoteAttachments={workNoteAttachments}
uploadingDocument={uploadingDocument}
documentPolicy={documentPolicy}
triggerFileInput={triggerFileInput}
setPreviewDocument={setPreviewDocument}
downloadDocument={downloadDocument}
/>
</TabsContent>
<TabsContent value="activity" className="mt-0">
<ActivityTab request={request} />
</TabsContent>
<TabsContent value="worknotes" className="mt-0" forceMount={true} hidden={activeTab !== 'worknotes'}>
<WorkNotesTab
requestId={requestIdentifier}
requestTitle={request.title}
mergedMessages={mergedMessages}
setWorkNoteAttachments={setWorkNoteAttachments}
isInitiator={isInitiator}
isSpectator={isSpectator}
currentLevels={currentLevels}
onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/>
</TabsContent>
</div>
{/* Right Column: Quick Actions Sidebar */}
{activeTab !== 'worknotes' && (
<QuickActionsSidebar
request={request}
isInitiator={isInitiator}
isSpectator={isSpectator}
currentApprovalLevel={currentApprovalLevel}
onAddApprover={() => setShowAddApproverModal(true)}
onAddSpectator={() => setShowAddSpectatorModal(true)}
onApprove={() => setShowApproveModal(true)}
onReject={() => setShowRejectModal(true)}
onPause={handlePause}
onResume={handleResume}
onRetrigger={handleRetrigger}
summaryId={summaryId}
refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
apiRequest={apiRequest}
/>
)}
</div>
</Tabs>
</div>
</div>
{/* Share Summary Modal */}
{showShareSummaryModal && summaryId && (
<ShareSummaryModal
isOpen={showShareSummaryModal}
onClose={() => setShowShareSummaryModal(false)}
summaryId={summaryId}
requestTitle={request?.title || 'N/A'}
onSuccess={() => {
refreshDetails();
setSharedRecipientsRefreshTrigger(prev => prev + 1);
}}
/>
)}
{/* Pause Modals */}
{showPauseModal && apiRequest?.requestId && (
<PauseModal
isOpen={showPauseModal}
onClose={() => setShowPauseModal(false)}
requestId={apiRequest.requestId}
levelId={currentApprovalLevel?.levelId || null}
onSuccess={handlePauseSuccess}
/>
)}
{showResumeModal && apiRequest?.requestId && (
<ResumeModal
isOpen={showResumeModal}
onClose={() => setShowResumeModal(false)}
requestId={apiRequest.requestId}
onSuccess={handleResumeSuccess}
/>
)}
{showRetriggerModal && apiRequest?.requestId && (
<RetriggerPauseModal
isOpen={showRetriggerModal}
onClose={() => setShowRetriggerModal(false)}
requestId={apiRequest.requestId}
approverName={request?.pauseInfo?.pausedBy?.name}
onSuccess={handleRetriggerSuccess}
/>
)}
{/* Modals */}
<RequestDetailModals
showApproveModal={showApproveModal}
showRejectModal={showRejectModal}
showAddApproverModal={showAddApproverModal}
showAddSpectatorModal={showAddSpectatorModal}
showSkipApproverModal={showSkipApproverModal}
showActionStatusModal={showActionStatusModal}
previewDocument={previewDocument}
documentError={documentError}
request={request}
skipApproverData={skipApproverData}
actionStatus={actionStatus}
existingParticipants={existingParticipants}
currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal}
setShowAddSpectatorModal={setShowAddSpectatorModal}
setShowSkipApproverModal={setShowSkipApproverModal}
setShowActionStatusModal={setShowActionStatusModal}
setPreviewDocument={setPreviewDocument}
setDocumentError={setDocumentError}
setSkipApproverData={setSkipApproverData}
setActionStatus={setActionStatus}
handleApproveConfirm={handleApproveConfirm}
handleRejectConfirm={handleRejectConfirm}
handleAddApprover={handleAddApprover}
handleAddSpectator={handleAddSpectator}
handleSkipApprover={handleSkipApprover}
downloadDocument={downloadDocument}
documentPolicy={documentPolicy}
/>
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators,
}}
/>
</>
);
}
/**
* Custom RequestDetail Component (Exported)
*/
export function CustomRequestDetail(props: RequestDetailProps) {
return (
<RequestDetailErrorBoundary>
<CustomRequestDetailInner {...props} />
</RequestDetailErrorBoundary>
);
}

View File

@ -0,0 +1,142 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X, CheckCircle, XCircle } from 'lucide-react';
interface DealerClosedRequestsFiltersProps {
searchTerm: string;
statusFilter?: string;
priorityFilter?: string;
templateTypeFilter?: string;
sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusChange?: (value: string) => void;
onPriorityChange?: (value: string) => void;
onTemplateTypeChange?: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Dealer Closed Requests Filters Component
*
* Simplified filters for dealer users viewing closed requests.
* Only includes: Search, Status (closure type), and Sort filters.
* Removes: Priority and Template Type filters.
*/
export function DealerClosedRequestsFilters({
searchTerm,
statusFilter = 'all',
sortBy,
sortOrder,
onSearchChange,
onStatusChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
...rest // Accept but ignore other props for interface compatibility
}: DealerClosedRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="shadow-lg border-0" data-testid="dealer-closed-requests-filters">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
data-testid="dealer-closed-requests-clear-filters"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Dealer-specific filters - Search, Status (Closure Type), and Sort */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
data-testid="dealer-closed-requests-search"
/>
</div>
{onStatusChange && (
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200" data-testid="dealer-closed-requests-status-filter">
<SelectValue placeholder="Closure Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Closures</SelectItem>
<SelectItem value="approved">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Closed After Approval</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span>Closed After Rejection</span>
</div>
</SelectItem>
</SelectContent>
</Select>
)}
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200" data-testid="dealer-closed-requests-sort-by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={onSortOrderChange}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
data-testid="dealer-closed-requests-sort-order"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,114 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X } from 'lucide-react';
interface DealerRequestsFiltersProps {
searchTerm: string;
statusFilter?: string;
priorityFilter?: string;
templateTypeFilter?: string;
sortBy: 'created' | 'due' | 'priority' | 'sla';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusFilterChange?: (value: string) => void;
onPriorityFilterChange?: (value: string) => void;
onTemplateTypeFilterChange?: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
onSortOrderChange: (value: 'asc' | 'desc') => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Dealer Requests Filters Component
*
* Simplified filters for dealer users.
* Only includes: Search and Sort filters (no status, priority, or template type).
*/
export function DealerRequestsFilters({
searchTerm,
sortBy,
sortOrder,
onSearchChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
...rest // Accept but ignore other props for interface compatibility
}: DealerRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="shadow-lg border-0">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Dealer-specific filters - Only Search and Sort */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,389 @@
/**
* Dealer User All Requests Filters Component
*
* Simplified filters for dealer users viewing their all requests.
* Only includes: Search, Status, Initiator, Approver, and Date Range filters.
* Removes: Priority, Template Type, Department, and SLA Compliance filters.
*/
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import type { DateRange } from '@/services/dashboard.service';
import { CustomDatePicker } from '@/components/ui/date-picker';
interface DealerUserAllRequestsFiltersProps {
// Filters
searchTerm: string;
statusFilter: string;
priorityFilter?: string;
templateTypeFilter?: string;
departmentFilter?: string;
slaComplianceFilter?: string;
initiatorFilter: string;
approverFilter: string;
approverFilterType: 'current' | 'any';
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
// State for user search
initiatorSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
approverSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
// Actions
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onInitiatorChange?: (value: string) => void;
onApproverChange?: (value: string) => void;
onApproverTypeChange?: (value: 'current' | 'any') => void;
onDateRangeChange: (value: DateRange) => void;
onCustomStartDateChange?: (date: Date | undefined) => void;
onCustomEndDateChange?: (date: Date | undefined) => void;
onShowCustomDatePickerChange?: (show: boolean) => void;
onApplyCustomDate?: () => void;
onClearFilters: () => void;
// Computed
hasActiveFilters: boolean;
}
export function DealerUserAllRequestsFilters({
searchTerm,
statusFilter,
initiatorFilter,
approverFilter,
approverFilterType,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
initiatorSearch,
approverSearch,
onSearchChange,
onStatusChange,
onInitiatorChange,
onApproverChange,
onApproverTypeChange,
onDateRangeChange,
onCustomStartDateChange,
onCustomEndDateChange,
onShowCustomDatePickerChange,
onApplyCustomDate,
onClearFilters,
hasActiveFilters,
...rest // Accept but ignore other props for interface compatibility
}: DealerUserAllRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="border-gray-200 shadow-md" data-testid="dealer-user-all-requests-filters">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Filters</h3>
{hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
<RefreshCw className="w-4 h-4" />
Clear All
</Button>
)}
</div>
<Separator />
{/* Primary Filters - Only Search and Status for dealers */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 h-10"
data-testid="dealer-search-input"
/>
</div>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-10" data-testid="dealer-status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
if (initiatorSearch.searchResults.length > 0) {
initiatorSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
className="h-10"
data-testid="dealer-initiator-search-input"
/>
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{approverFilter !== 'all' && onApproverTypeChange && (
<Select
value={approverFilterType}
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {
if (approverSearch.searchResults.length > 0) {
approverSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
className="h-10"
data-testid="dealer-approver-search-input"
/>
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="last7days">Last 7 Days</SelectItem>
<SelectItem value="last30days">Last 30 Days</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<CustomDatePicker
value={customStartDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomStartDateChange?.(date);
if (customEndDate && date > customEndDate) {
onCustomEndDateChange?.(date);
}
} else {
onCustomStartDateChange?.(undefined);
}
}}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<CustomDatePicker
value={customEndDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomEndDateChange?.(date);
if (customStartDate && date < customStartDate) {
onCustomStartDateChange?.(date);
}
} else {
onCustomEndDateChange?.(undefined);
}
}}
minDate={customStartDate || undefined}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApplyCustomDate}
disabled={!customStartDate || !customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
onShowCustomDatePickerChange?.(false);
onCustomStartDateChange?.(undefined);
onCustomEndDateChange?.(undefined);
onDateRangeChange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,503 @@
/**
* Dealer Claim IO Tab
*
* This component handles IO (Internal Order) management for dealer claims.
* Located in: src/dealer-claim/components/request-detail/
*/
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
import { toast } from 'sonner';
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
import { useAuth } from '@/contexts/AuthContext';
interface IOTabProps {
request: any;
apiRequest?: any;
onRefresh?: () => void;
}
interface IOBlockedDetails {
ioNumber: string;
blockedAmount: number;
availableBalance: number; // Available amount before block
remainingBalance: number; // Remaining amount after block
blockedDate: string;
blockedBy: string; // User who blocked
sapDocumentNumber: string;
status: 'blocked' | 'released' | 'failed';
}
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const { user } = useAuth();
const requestId = apiRequest?.requestId || request?.requestId;
// Load existing IO data from apiRequest or request
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || '';
// Get organizer user object from association (organizer) or fallback to organizedBy UUID
const organizer = internalOrder?.organizer || null;
// Get estimated budget from proposal details
const proposalDetails = apiRequest?.proposalDetails || {};
const estimatedBudget = Number(proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
const [ioNumber, setIoNumber] = useState(existingIONumber);
const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO block details from apiRequest
useEffect(() => {
if (internalOrder && existingIONumber) {
// IMPORTANT: ioAvailableBalance is already the available balance BEFORE blocking
// We should NOT add blockedAmount to it - that would cause double deduction
// Backend stores: availableBalance (before block), blockedAmount, remainingBalance (after block)
const availableBeforeBlock = Number(existingAvailableBalance) || 0;
// Get blocked by user name from organizer association (who blocked the amount)
// When amount is blocked, organizedBy stores the user who blocked it
const blockedByName = organizer?.displayName ||
organizer?.display_name ||
organizer?.name ||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
organizer?.email ||
'Unknown User';
// Set IO number from existing data
setIoNumber(existingIONumber);
// Only set blocked details if amount is blocked
if (existingBlockedAmount > 0) {
const blockedAmt = Number(existingBlockedAmount) || 0;
const backendRemaining = Number(existingRemainingBalance) || 0;
// Calculate expected remaining balance for validation/debugging
// Formula: remaining = availableBeforeBlock - blockedAmount
const expectedRemaining = availableBeforeBlock - blockedAmt;
// Loading existing IO block
// Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(backendRemaining - expectedRemaining) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
availableBalance: availableBeforeBlock,
blockedAmount: blockedAmt,
expectedRemaining,
backendRemaining,
difference: backendRemaining - expectedRemaining,
});
}
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: blockedAmt,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: backendRemaining, // Use backend calculated value
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: sapDocNumber,
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
});
// Set fetched amount if available balance exists
if (availableBeforeBlock > 0) {
setFetchedAmount(availableBeforeBlock);
}
}
}
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/**
* Fetch available budget from SAP
* Validates IO number and gets available balance (returns dummy data for now)
* Does not store anything in database - only validates
*/
const handleFetchAmount = async () => {
if (!ioNumber.trim()) {
toast.error('Please enter an IO number');
return;
}
if (!requestId) {
toast.error('Request ID not found');
return;
}
setFetchingAmount(true);
try {
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
const ioData = await validateIO(requestId, ioNumber.trim());
if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance);
// Pre-fill amount to block with estimated budget (if available), otherwise use available balance
if (estimatedBudget > 0) {
setAmountToBlock(String(estimatedBudget));
} else {
setAmountToBlock(String(ioData.availableBalance));
}
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
} else {
toast.error('Invalid IO number or no available balance found');
setFetchedAmount(null);
setAmountToBlock('');
}
} catch (error: any) {
console.error('Failed to fetch IO budget:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to validate IO number or fetch budget from SAP';
toast.error(errorMessage);
setFetchedAmount(null);
} finally {
setFetchingAmount(false);
}
};
/**
* Block budget in SAP system
* This function:
* 1. Validates the IO number and amount
* 2. Calls SAP to block the budget
* 3. Saves IO number, blocked amount, and balance details to database
*/
const handleBlockBudget = async () => {
if (!ioNumber.trim() || fetchedAmount === null) {
toast.error('Please fetch IO amount first');
return;
}
if (!requestId) {
toast.error('Request ID not found');
return;
}
const blockAmountRaw = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
toast.error('Please enter a valid amount to block');
return;
}
// Round to exactly 2 decimal places to avoid floating point precision issues
// Use parseFloat with toFixed to ensure exact 2 decimal precision
const blockAmount = parseFloat(blockAmountRaw.toFixed(2));
if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget');
return;
}
// Validate that amount to block must exactly match estimated budget
if (estimatedBudget > 0) {
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
if (Math.abs(blockAmount - roundedEstimatedBudget) > 0.01) {
toast.error(`Amount to block must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`);
return;
}
}
// Blocking budget
setBlockingBudget(true);
try {
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
// This will store in internal_orders and claim_budget_tracking tables
// Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only
// Ensure all amounts are rounded to 2 decimal places for consistency
const roundedFetchedAmount = parseFloat(fetchedAmount.toFixed(2));
const calculatedRemaining = parseFloat((roundedFetchedAmount - blockAmount).toFixed(2));
const payload = {
ioNumber: ioNumber.trim(),
ioAvailableBalance: roundedFetchedAmount,
ioBlockedAmount: blockAmount,
ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value)
};
// Sending to backend
await updateIODetails(requestId, payload);
// Fetch updated claim details to get the blocked IO data
const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
if (updatedInternalOrder) {
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
// Calculate expected remaining balance for validation/debugging
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
// Blocking result processed
// Warn if the saved amount differs from what we sent
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
}
// Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
availableBalance: fetchedAmount,
blockedAmount: savedBlockedAmount,
expectedRemaining: expectedRemainingBalance,
backendRemaining: savedRemainingBalance,
difference: savedRemainingBalance - expectedRemainingBalance,
});
}
const currentUser = user as any;
// When blocking, always use the current user who is performing the block action
// The organizer association may be from initial IO organization, but we want who blocked the amount
const blockedByName = currentUser?.displayName ||
currentUser?.display_name ||
currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email ||
'Current User';
const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: savedBlockedAmount,
availableBalance: fetchedAmount, // Available amount before block
remainingBalance: savedRemainingBalance,
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
status: 'blocked',
};
setBlockedDetails(blocked);
setAmountToBlock(''); // Clear the input
toast.success('IO budget blocked successfully in SAP');
// Refresh request details
onRefresh?.();
} else {
toast.error('IO blocked but failed to fetch updated details');
onRefresh?.();
}
} catch (error: any) {
console.error('Failed to block IO budget:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to block IO budget in SAP';
toast.error(errorMessage);
} finally {
setBlockingBudget(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IO Budget Management Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[#2d4a3e]" />
IO Budget Management
</CardTitle>
<CardDescription>
Enter IO number to fetch available budget from SAP
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* IO Number Input */}
<div className="space-y-3">
<Label htmlFor="ioNumber">IO Number *</Label>
<div className="flex gap-2">
<Input
id="ioNumber"
placeholder="Enter IO number (e.g., IO-2024-12345)"
value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)}
disabled={fetchingAmount || !!blockedDetails}
className="flex-1"
/>
<Button
onClick={handleFetchAmount}
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Download className="w-4 h-4 mr-2" />
{fetchingAmount ? 'Fetching...' : 'Fetch Amount'}
</Button>
</div>
</div>
{/* Instructions when IO number is entered but not fetched */}
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
</p>
</div>
)}
{/* Fetched Amount Display */}
{fetchedAmount !== null && !blockedDetails && (
<>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 uppercase tracking-wide mb-1">Available Amount</p>
<p className="text-2xl font-bold text-green-700">
{fetchedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<CircleCheckBig className="w-8 h-8 text-green-600" />
</div>
<div className="mt-3 pt-3 border-t border-green-200">
<p className="text-xs text-gray-600"><strong>IO Number:</strong> {ioNumber}</p>
<p className="text-xs text-gray-600 mt-1"><strong>Fetched from:</strong> SAP System</p>
</div>
</div>
{/* Amount to Block Input */}
<div className="space-y-3">
<Label htmlFor="blockAmount">Amount to Block *</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></span>
<Input
type="number"
id="blockAmount"
placeholder="Enter amount to block"
min="0"
step="0.01"
value={amountToBlock}
onChange={(e) => setAmountToBlock(e.target.value)}
className="pl-8"
/>
</div>
{estimatedBudget > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
<strong>Required:</strong> Amount must be exactly equal to the estimated budget: <strong>{estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>
</p>
</div>
)}
</div>
{/* Block Button */}
<Button
onClick={handleBlockBudget}
disabled={
blockingBudget ||
!amountToBlock ||
parseFloat(amountToBlock) <= 0 ||
parseFloat(amountToBlock) > fetchedAmount ||
(estimatedBudget > 0 && Math.abs(parseFloat(amountToBlock) - estimatedBudget) > 0.01)
}
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Target className="w-4 h-4 mr-2" />
{blockingBudget ? 'Blocking in SAP...' : 'Block IO in SAP'}
</Button>
</>
)}
</CardContent>
</Card>
{/* IO Blocked Details Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CircleCheckBig className="w-5 h-5 text-green-600" />
IO Blocked Details
</CardTitle>
<CardDescription>
Details of IO blocked in SAP system
</CardDescription>
</CardHeader>
<CardContent>
{blockedDetails ? (
<div className="space-y-4">
{/* Success Banner */}
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
<div className="flex items-start gap-3">
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
</div>
</div>
</div>
{/* Blocked Details */}
<div className="border rounded-lg divide-y">
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
</div>
<div className="p-4 bg-green-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
<p className="text-xl font-bold text-green-700">
{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
<p className="text-sm font-medium text-gray-900">
{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4 bg-blue-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
<p className="text-sm font-bold text-blue-700">
{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
<p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked At</p>
<p className="text-sm font-medium text-gray-900">
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
</p>
</div>
<div className="p-4 bg-gray-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
<Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" />
Blocked
</Badge>
</div>
</div>
</div>
) : (
<div className="text-center py-12">
<DollarSign className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-sm text-gray-500 mb-2">No IO blocked yet</p>
<p className="text-xs text-gray-400">
Enter IO number and fetch amount to block budget
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,296 @@
/**
* Dealer Claim Request Overview Tab
*
* This component is specific to Dealer Claim requests.
* Located in: src/dealer-claim/components/request-detail/
*/
import {
ActivityInformationCard,
DealerInformationCard,
ProposalDetailsCard,
RequestInitiatorCard,
} from './claim-cards';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import {
mapToClaimManagementRequest,
determineUserRole,
getRoleBasedVisibility,
type RequestRole,
} from '@/utils/claimDataMapper';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { FormattedDescription } from '@/components/common/FormattedDescription';
import { CheckCircle, RefreshCw, Loader2 } from 'lucide-react';
import { formatDateTime } from '@/utils/dateFormatter';
interface ClaimManagementOverviewTabProps {
request: any; // Original request object
apiRequest: any; // API request data
currentUserId: string;
isInitiator: boolean;
onEditClaimAmount?: () => void;
className?: string;
// Closure props
needsClosure?: boolean;
conclusionRemark?: string;
setConclusionRemark?: (value: string) => void;
conclusionLoading?: boolean;
conclusionSubmitting?: boolean;
aiGenerated?: boolean;
handleGenerateConclusion?: () => void;
handleFinalizeConclusion?: () => void;
generationAttempts?: number;
generationFailed?: boolean;
maxAttemptsReached?: boolean;
}
export function ClaimManagementOverviewTab({
request: _request,
apiRequest,
currentUserId,
isInitiator: _isInitiator,
onEditClaimAmount: _onEditClaimAmount,
className = '',
needsClosure = false,
conclusionRemark = '',
setConclusionRemark,
conclusionLoading = false,
conclusionSubmitting = false,
aiGenerated = false,
handleGenerateConclusion,
handleFinalizeConclusion,
generationAttempts = 0,
generationFailed = false,
maxAttemptsReached = false,
}: ClaimManagementOverviewTabProps) {
// Check if this is a claim management request
if (!isClaimManagementRequest(apiRequest)) {
return (
<div className="text-center py-8 text-gray-500">
<p>This is not a claim management request.</p>
</div>
);
}
// Map API data to claim management structure
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
if (!claimRequest) {
console.warn('[ClaimManagementOverviewTab] Failed to map claim data:', {
apiRequest,
hasClaimDetails: !!apiRequest?.claimDetails,
hasProposalDetails: !!apiRequest?.proposalDetails,
hasCompletionDetails: !!apiRequest?.completionDetails,
});
return (
<div className="text-center py-8 text-gray-500">
<p>Unable to load claim management data.</p>
<p className="text-xs mt-2">Please ensure the request has been properly initialized.</p>
</div>
);
}
// Mapped claim data ready
// Determine user's role
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
// Get visibility settings based on role
const visibility = getRoleBasedVisibility(userRole);
// User role and visibility determined
// Extract initiator info from request
// The apiRequest has initiator object with displayName, email, department, phone, etc.
const initiatorInfo = {
name: apiRequest.initiator?.name || apiRequest.initiator?.displayName || apiRequest.initiator?.email || 'Unknown',
role: apiRequest.initiator?.role || apiRequest.initiator?.designation || 'Initiator',
department: apiRequest.initiator?.department || apiRequest.department || '',
email: apiRequest.initiator?.email || 'N/A',
phone: apiRequest.initiator?.phone || apiRequest.initiator?.mobile,
};
// Closure setup check completed
return (
<div className={`space-y-6 ${className}`}>
{/* Activity Information - Always visible */}
{/* Dealer-claim module: Business logic for preparing timestamp data */}
<ActivityInformationCard
activityInfo={claimRequest.activityInfo}
createdAt={apiRequest?.createdAt}
updatedAt={apiRequest?.updatedAt}
/>
{/* Dealer Information - Always visible */}
<DealerInformationCard dealerInfo={claimRequest.dealerInfo} />
{/* Proposal Details - Only shown after dealer submits proposal */}
{visibility.showProposalDetails && claimRequest.proposalDetails && (
<ProposalDetailsCard proposalDetails={claimRequest.proposalDetails} />
)}
{/* Request Initiator */}
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
{/* Closed Request Conclusion Remark Display */}
{apiRequest?.status === 'closed' && apiRequest?.conclusionRemark && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle className="w-5 h-5 text-gray-600" />
Conclusion Remark
</CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm">Final summary of this closed request</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<FormattedDescription
content={apiRequest.conclusionRemark || ''}
className="text-sm"
/>
</div>
{apiRequest.closureDate && (
<div className="mt-3 flex items-center justify-between text-xs text-gray-500 border-t border-gray-200 pt-3">
<span>Request closed on {formatDateTime(apiRequest.closureDate)}</span>
<span>By {initiatorInfo.name}</span>
</div>
)}
</CardContent>
</Card>
)}
{/* Conclusion Remark Section - Closure Setup */}
{needsClosure && (
<Card data-testid="conclusion-remark-card">
<CardHeader className={`bg-gradient-to-r border-b ${
(apiRequest?.status || '').toLowerCase() === 'rejected'
? 'from-red-50 to-rose-50 border-red-200'
: 'from-green-50 to-emerald-50 border-green-200'
}`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
}`}>
<CheckCircle className={`w-5 h-5 ${
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
Conclusion Remark - Final Step
</CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm">
{(apiRequest?.status || '').toLowerCase() === 'rejected'
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
</CardDescription>
</div>
{handleGenerateConclusion && (
<div className="flex flex-col items-end gap-1.5">
<Button
variant="outline"
size="sm"
onClick={handleGenerateConclusion}
disabled={conclusionLoading || maxAttemptsReached}
className="gap-2 shrink-0 h-9"
data-testid="generate-ai-conclusion-button"
>
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && (
<span className="text-[10px] text-gray-500 font-medium px-1">
{2 - generationAttempts} attempts remaining
</span>
)}
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-4">
{conclusionLoading ? (
<div className="flex items-center justify-center py-8" data-testid="conclusion-loading">
<div className="text-center">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mx-auto mb-2" />
<p className="text-sm text-gray-600">Preparing conclusion remark...</p>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">Conclusion Remark</label>
{aiGenerated && (
<span className="text-xs text-blue-600" data-testid="ai-generated-label">
System-generated suggestion (editable)
</span>
)}
</div>
{setConclusionRemark && (
<RichTextEditor
value={conclusionRemark}
onChange={(html) => setConclusionRemark(html)}
placeholder="Enter a professional conclusion remark summarizing the request outcome, key decisions, and approvals..."
className="text-sm"
minHeight="160px"
data-testid="conclusion-remark-textarea"
/>
)}
<p className="text-xs text-blue-600 mt-1">
💡 Tip: You can paste formatted content (lists, tables) and the formatting will be preserved.
</p>
<div className="flex items-center justify-between mt-2">
<p className="text-xs text-gray-500">This will be the final summary for this request</p>
<p className="text-xs text-gray-500" data-testid="character-count">
{conclusionRemark ? conclusionRemark.replace(/<[^>]*>/g, '').length : 0} / 2000 characters
</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-xs sm:text-sm font-semibold text-blue-900 mb-1.5">Finalizing this request will:</p>
<ul className="text-xs sm:text-sm text-blue-800 space-y-0.5 pl-4">
<li className="list-disc">Change request status to "CLOSED"</li>
<li className="list-disc">Notify all participants of closure</li>
<li className="list-disc">Move request to Closed Requests</li>
<li className="list-disc">Save conclusion remark permanently</li>
</ul>
</div>
{handleFinalizeConclusion && (
<div className="flex gap-3 justify-end pt-3 border-t">
<Button
onClick={handleFinalizeConclusion}
disabled={conclusionSubmitting || !conclusionRemark.trim()}
className="bg-green-600 hover:bg-green-700 text-white"
data-testid="finalize-close-button"
>
{conclusionSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Finalizing...
</>
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Finalize & Close Request
</>
)}
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}
// Export as DealerClaimOverviewTab for consistency
export { ClaimManagementOverviewTab as DealerClaimOverviewTab };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,206 @@
/**
* ActivityInformationCard Component
* Displays activity details for Claim Management requests
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Calendar, MapPin, DollarSign, Receipt } from 'lucide-react';
import { ClaimActivityInfo } from '@/pages/RequestDetail/types/claimManagement.types';
import { format } from 'date-fns';
import { formatDateTime } from '@/utils/dateFormatter';
import { FormattedDescription } from '@/components/common/FormattedDescription';
interface ActivityInformationCardProps {
activityInfo: ClaimActivityInfo;
className?: string;
// Plug-and-play: Pass timestamps from module's business logic
createdAt?: string | Date;
updatedAt?: string | Date;
}
export function ActivityInformationCard({
activityInfo,
className,
createdAt,
updatedAt
}: ActivityInformationCardProps) {
// Defensive check: Ensure activityInfo exists
if (!activityInfo) {
console.warn('[ActivityInformationCard] activityInfo is missing');
return (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Activity information not available</p>
</CardContent>
</Card>
);
}
const formatCurrency = (amount: string | number) => {
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(numAmount)) return 'N/A';
return `${numAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'N/A';
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return dateString;
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="w-5 h-5 text-blue-600" />
Activity Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Activity Name */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Activity Name
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{activityInfo.activityName}
</p>
</div>
{/* Activity Type */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Activity Type
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{activityInfo.activityType}
</p>
</div>
{/* Location */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Location
</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
{activityInfo.location}
</p>
</div>
{/* Requested Date */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Requested Date
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{formatDate(activityInfo.requestedDate)}
</p>
</div>
{/* Estimated Budget */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Estimated Budget
</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" />
{activityInfo.estimatedBudget
? formatCurrency(activityInfo.estimatedBudget)
: 'TBD'}
</p>
</div>
{/* Closed Expenses - Show if value exists (including 0) */}
{activityInfo.closedExpenses !== undefined && activityInfo.closedExpenses !== null && (
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Closed Expenses
</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<Receipt className="w-4 h-4 text-blue-600" />
{formatCurrency(activityInfo.closedExpenses)}
</p>
</div>
)}
{/* Period */}
{activityInfo.period && (
<div className="col-span-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Period
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{formatDate(activityInfo.period.startDate)} - {formatDate(activityInfo.period.endDate)}
</p>
</div>
)}
</div>
{/* Closed Expenses Breakdown */}
{activityInfo.closedExpensesBreakdown && activityInfo.closedExpensesBreakdown.length > 0 && (
<div className="pt-4 border-t">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
Closed Expenses Breakdown
</label>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
{activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => (
<div key={index} className="flex justify-between items-center text-sm">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
<div className="pt-2 border-t border-blue-300 flex justify-between items-center">
<span className="font-semibold text-gray-900">Total</span>
<span className="font-bold text-blue-600">
{formatCurrency(
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0)
)}
</span>
</div>
</div>
</div>
)}
{/* Description */}
{activityInfo.description && (
<div className="pt-4 border-t">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Description
</label>
<div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200">
<FormattedDescription
content={activityInfo.description || ''}
className="text-sm"
/>
</div>
</div>
)}
{/* Timestamps - Similar to Request Details Card */}
{(createdAt || updatedAt) && (
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-300">
{createdAt && (
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Created</label>
<p className="text-sm text-gray-900 font-medium mt-1">{formatDateTime(createdAt)}</p>
</div>
)}
{updatedAt && (
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Last Updated</label>
<p className="text-sm text-gray-900 font-medium mt-1">{formatDateTime(updatedAt)}</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,118 @@
/**
* CloserCard Component
* Displays who closed the request and closure details
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Mail, Calendar, FileText } from 'lucide-react';
import { formatDateTime } from '@/utils/dateFormatter';
interface CloserInfo {
name?: string;
role?: string;
department?: string;
email?: string;
closureDate?: string;
conclusionRemark?: string;
}
interface CloserCardProps {
closerInfo: CloserInfo;
className?: string;
}
export function CloserCard({ closerInfo, className }: CloserCardProps) {
// If no closure date, don't render
if (!closerInfo.closureDate) {
return null;
}
// Generate initials from name
const getInitials = (name?: string) => {
if (!name) return 'CL';
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
const closerName = closerInfo.name || 'System';
const hasCloserInfo = closerInfo.name || closerInfo.email;
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base">Request Closer</CardTitle>
</CardHeader>
<CardContent>
{hasCloserInfo ? (
<div className="flex items-start gap-4">
<Avatar className="h-14 w-14 ring-2 ring-white shadow-md">
<AvatarFallback className="bg-green-700 text-white font-semibold text-lg">
{getInitials(closerInfo.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{closerName}</h3>
{closerInfo.role && (
<p className="text-sm text-gray-600">{closerInfo.role}</p>
)}
{closerInfo.department && (
<p className="text-sm text-gray-500">{closerInfo.department}</p>
)}
<div className="mt-3 space-y-2">
{/* Email */}
{closerInfo.email && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span>{closerInfo.email}</span>
</div>
)}
{/* Closure Date */}
{closerInfo.closureDate && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Calendar className="w-4 h-4" />
<span>Closed on {formatDateTime(closerInfo.closureDate, { includeTime: true, format: 'short' })}</span>
</div>
)}
</div>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-gray-600">Request closed by system</p>
{closerInfo.closureDate && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Calendar className="w-4 h-4" />
<span>Closed on {formatDateTime(closerInfo.closureDate, { includeTime: true, format: 'short' })}</span>
</div>
)}
</div>
)}
{/* Conclusion Remark */}
{closerInfo.conclusionRemark && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex items-start gap-2">
<FileText className="w-4 h-4 text-gray-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider mb-1">
Conclusion Remark
</p>
<p className="text-sm text-gray-700 whitespace-pre-wrap">
{closerInfo.conclusionRemark}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,98 @@
/**
* DealerInformationCard Component
* Displays dealer details for Claim Management requests
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Building, Mail, Phone, MapPin } from 'lucide-react';
import { DealerInfo } from '@/pages/RequestDetail/types/claimManagement.types';
interface DealerInformationCardProps {
dealerInfo: DealerInfo;
className?: string;
}
export function DealerInformationCard({ dealerInfo, className }: DealerInformationCardProps) {
// Defensive check: Ensure dealerInfo exists
if (!dealerInfo) {
console.warn('[DealerInformationCard] dealerInfo is missing');
return (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Dealer information not available</p>
</CardContent>
</Card>
);
}
// Check if essential fields are present
if (!dealerInfo.dealerCode && !dealerInfo.dealerName) {
console.warn('[DealerInformationCard] Dealer info missing essential fields:', dealerInfo);
return (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Dealer information incomplete</p>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="w-5 h-5 text-purple-600" />
Dealer Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Dealer Code and Name */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Code
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{dealerInfo.dealerCode}
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Name
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{dealerInfo.dealerName}
</p>
</div>
</div>
{/* Contact Information */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Contact Information
</label>
<div className="mt-2 space-y-2">
{/* Email */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4 text-gray-400" />
<span>{dealerInfo.email}</span>
</div>
{/* Phone */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4 text-gray-400" />
<span>{dealerInfo.phone}</span>
</div>
{/* Address */}
<div className="flex items-start gap-2 text-sm text-gray-700">
<MapPin className="w-4 h-4 text-gray-400 mt-0.5" />
<span>{dealerInfo.address}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,291 @@
/**
* ProcessDetailsCard Component
* Displays process-related details: IO Number, DMS Number, Claim Amount, and Budget Breakdowns
* Visibility controlled by user role
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Activity, Receipt, DollarSign, Pen } from 'lucide-react';
import { format } from 'date-fns';
// Local minimal types to avoid external dependency issues
interface IODetails {
ioNumber?: string;
remarks?: string;
availableBalance?: number;
blockedAmount?: number;
remainingBalance?: number;
blockedByName?: string;
blockedAt?: string;
}
interface DMSDetails {
dmsNumber?: string;
remarks?: string;
createdByName?: string;
createdAt?: string;
}
interface ClaimAmountDetails {
amount: number;
lastUpdatedBy?: string;
lastUpdatedAt?: string;
}
interface CostBreakdownItem {
description: string;
amount: number;
}
interface RoleBasedVisibility {
showIODetails: boolean;
showDMSDetails: boolean;
showClaimAmount: boolean;
canEditClaimAmount: boolean;
}
interface ProcessDetailsCardProps {
ioDetails?: IODetails;
dmsDetails?: DMSDetails;
claimAmount?: ClaimAmountDetails;
estimatedBudgetBreakdown?: CostBreakdownItem[];
closedExpensesBreakdown?: CostBreakdownItem[];
visibility: RoleBasedVisibility;
onEditClaimAmount?: () => void;
className?: string;
}
export function ProcessDetailsCard({
ioDetails,
dmsDetails,
claimAmount,
estimatedBudgetBreakdown,
closedExpensesBreakdown,
visibility,
onEditClaimAmount,
className,
}: ProcessDetailsCardProps) {
const formatCurrency = (amount?: number | null) => {
if (amount === undefined || amount === null || Number.isNaN(amount)) {
return '₹0.00';
}
return `${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString || '';
}
};
const calculateTotal = (items?: CostBreakdownItem[]) => {
if (!items || items.length === 0) return 0;
return items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
};
// Don't render if nothing to show
const hasContent =
(visibility.showIODetails && ioDetails) ||
(visibility.showDMSDetails && dmsDetails) ||
(visibility.showClaimAmount && claimAmount && claimAmount.amount !== undefined && claimAmount.amount !== null) ||
(estimatedBudgetBreakdown && estimatedBudgetBreakdown.length > 0) ||
(closedExpensesBreakdown && closedExpensesBreakdown.length > 0);
if (!hasContent) {
return null;
}
return (
<Card className={`bg-gradient-to-br from-blue-50 to-purple-50 border-2 border-blue-200 ${className}`}>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Activity className="w-4 h-4 text-blue-600" />
Process Details
</CardTitle>
<CardDescription>Workflow reference numbers</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* IO Details - Only visible to internal RE users */}
{visibility.showIODetails && ioDetails && (
<div className="bg-white rounded-lg p-3 border border-blue-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-blue-600" />
<Label className="text-xs font-semibold text-blue-900 uppercase tracking-wide">
IO Number
</Label>
</div>
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
{ioDetails.remarks && (
<div className="pt-2 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">Remark:</p>
<p className="text-xs text-gray-900">{ioDetails.remarks}</p>
</div>
)}
{/* Budget Details */}
{(ioDetails.availableBalance !== undefined || ioDetails.blockedAmount !== undefined) && (
<div className="pt-2 border-t border-blue-100 mt-2 space-y-1">
{ioDetails.availableBalance !== undefined && (
<div className="flex justify-between text-xs">
<span className="text-gray-600">Available Balance:</span>
<span className="font-medium text-gray-900">
{formatCurrency(ioDetails.availableBalance)}
</span>
</div>
)}
{ioDetails.blockedAmount !== undefined && (
<div className="flex justify-between text-xs">
<span className="text-gray-600">Blocked Amount:</span>
<span className="font-medium text-blue-700">
{formatCurrency(ioDetails.blockedAmount)}
</span>
</div>
)}
{ioDetails.remainingBalance !== undefined && (
<div className="flex justify-between text-xs">
<span className="text-gray-600">Remaining Balance:</span>
<span className="font-medium text-green-700">
{formatCurrency(ioDetails.remainingBalance)}
</span>
</div>
)}
</div>
)}
<div className="pt-2 border-t border-blue-100 mt-2">
<p className="text-xs text-gray-500">By {ioDetails.blockedByName}</p>
<p className="text-xs text-gray-500">{formatDate(ioDetails.blockedAt)}</p>
</div>
</div>
)}
{/* DMS Details */}
{visibility.showDMSDetails && dmsDetails && (
<div className="bg-white rounded-lg p-3 border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" />
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
DMS Number
</Label>
</div>
<p className="font-bold text-gray-900 mb-2">{dmsDetails.dmsNumber}</p>
{dmsDetails.remarks && (
<div className="pt-2 border-t border-purple-100">
<p className="text-xs text-gray-600 mb-1">Remarks:</p>
<p className="text-xs text-gray-900">{dmsDetails.remarks}</p>
</div>
)}
<div className="pt-2 border-t border-purple-100 mt-2">
<p className="text-xs text-gray-500">By {dmsDetails.createdByName}</p>
<p className="text-xs text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
</div>
</div>
)}
{/* Claim Amount */}
{visibility.showClaimAmount && claimAmount && (
<div className="bg-white rounded-lg p-3 border border-green-200">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" />
<Label className="text-xs font-semibold text-green-900 uppercase tracking-wide">
Claim Amount
</Label>
</div>
{visibility.canEditClaimAmount && onEditClaimAmount && (
<Button
variant="outline"
size="sm"
onClick={onEditClaimAmount}
className="h-7 px-2 text-xs border-green-300 hover:bg-green-50"
>
<Pen className="w-3 h-3 mr-1 text-green-700" />
Edit
</Button>
)}
</div>
<p className="text-2xl font-bold text-green-700">
{formatCurrency(claimAmount.amount)}
</p>
{claimAmount.lastUpdatedBy && (
<div className="mt-2 pt-2 border-t border-green-100">
<p className="text-xs text-gray-500">
Last updated by {claimAmount.lastUpdatedBy}
</p>
{claimAmount.lastUpdatedAt && (
<p className="text-xs text-gray-500">
{formatDate(claimAmount.lastUpdatedAt)}
</p>
)}
</div>
)}
</div>
)}
{/* Estimated Budget Breakdown */}
{estimatedBudgetBreakdown && estimatedBudgetBreakdown.length > 0 && (
<div className="bg-white rounded-lg p-3 border border-amber-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-amber-600" />
<Label className="text-xs font-semibold text-amber-900 uppercase tracking-wide">
Estimated Budget Breakdown
</Label>
</div>
<div className="space-y-1.5 pt-1">
{estimatedBudgetBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
<div className="pt-2 border-t border-amber-200 flex justify-between items-center">
<span className="font-semibold text-gray-900 text-xs">Total</span>
<span className="font-bold text-amber-700">
{formatCurrency(calculateTotal(estimatedBudgetBreakdown))}
</span>
</div>
</div>
</div>
)}
{/* Closed Expenses Breakdown */}
{closedExpensesBreakdown && closedExpensesBreakdown.length > 0 && (
<div className="bg-white rounded-lg p-3 border border-indigo-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-indigo-600" />
<Label className="text-xs font-semibold text-indigo-900 uppercase tracking-wide">
Closed Expenses Breakdown
</Label>
</div>
<div className="space-y-1.5 pt-1">
{closedExpensesBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
<div className="pt-2 border-t border-indigo-200 flex justify-between items-center">
<span className="font-semibold text-gray-900 text-xs">Total</span>
<span className="font-bold text-indigo-700">
{formatCurrency(calculateTotal(closedExpensesBreakdown))}
</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,159 @@
/**
* ProposalDetailsCard Component
* Displays proposal details submitted by dealer for Claim Management requests
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Receipt, Calendar } from 'lucide-react';
import { format } from 'date-fns';
// Minimal local types to avoid missing imports during runtime
interface ProposalCostItem {
description: string;
amount?: number | null;
}
interface ProposalDetails {
costBreakup: ProposalCostItem[];
estimatedBudgetTotal?: number | null;
timelineForClosure?: string | null;
dealerComments?: string | null;
submittedOn?: string | null;
}
interface ProposalDetailsCardProps {
proposalDetails: ProposalDetails;
className?: string;
}
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
// Calculate estimated total from costBreakup if not provided
const calculateEstimatedTotal = () => {
if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
return proposalDetails.estimatedBudgetTotal;
}
// Calculate sum from costBreakup items
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
const total = proposalDetails.costBreakup.reduce((sum, item) => {
const amount = item.amount || 0;
return sum + (Number.isNaN(amount) ? 0 : amount);
}, 0);
return total;
}
return 0;
};
const estimatedTotal = calculateEstimatedTotal();
const formatCurrency = (amount?: number | null) => {
if (amount === undefined || amount === null || Number.isNaN(amount)) {
return '₹0.00';
}
return `${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString || '';
}
};
const formatTimelineDate = (dateString?: string | null) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return dateString || '-';
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="w-5 h-5 text-green-600" />
Proposal Details
</CardTitle>
{proposalDetails.submittedOn && (
<CardDescription>
Submitted on {formatDate(proposalDetails.submittedOn)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* Cost Breakup */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
Cost Breakup
</label>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-700 uppercase tracking-wide">
Item Description
</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
Amount
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{(proposalDetails.costBreakup || []).map((item, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900">
{item.description}
</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right font-medium">
{formatCurrency(item.amount)}
</td>
</tr>
))}
<tr className="bg-green-50 font-semibold">
<td className="px-4 py-3 text-sm text-gray-900">
Estimated Budget (Total)
</td>
<td className="px-4 py-3 text-sm text-green-700 text-right">
{formatCurrency(estimatedTotal)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Timeline for Closure */}
<div className="pt-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Timeline for Closure
</label>
<div className="mt-2 bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-gray-900">
Expected completion by: {formatTimelineDate(proposalDetails.timelineForClosure)}
</span>
</div>
</div>
</div>
{/* Dealer Comments */}
{proposalDetails.dealerComments && (
<div className="pt-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Comments
</label>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{proposalDetails.dealerComments}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,75 @@
/**
* RequestInitiatorCard Component
* Displays initiator/requester details - can be used for both claim management and regular workflows
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Mail, Phone } from 'lucide-react';
interface InitiatorInfo {
name: string;
role?: string;
department?: string;
email: string;
phone?: string;
}
interface RequestInitiatorCardProps {
initiatorInfo: InitiatorInfo;
className?: string;
}
export function RequestInitiatorCard({ initiatorInfo, className }: RequestInitiatorCardProps) {
// Generate initials from name
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base">Request Initiator</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-4">
<Avatar className="h-14 w-14 ring-2 ring-white shadow-md">
<AvatarFallback className="bg-gray-700 text-white font-semibold text-lg">
{getInitials(initiatorInfo.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{initiatorInfo.name}</h3>
{initiatorInfo.role && (
<p className="text-sm text-gray-600">{initiatorInfo.role}</p>
)}
{initiatorInfo.department && (
<p className="text-sm text-gray-500">{initiatorInfo.department}</p>
)}
<div className="mt-3 space-y-2">
{/* Email */}
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span>{initiatorInfo.email}</span>
</div>
{/* Phone */}
{initiatorInfo.phone && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="w-4 h-4" />
<span>{initiatorInfo.phone}</span>
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,13 @@
/**
* Dealer Claim Request Detail Cards
*
* These components are specific to Dealer Claim request details.
* Located in: src/dealer-claim/components/request-detail/claim-cards/
*/
export { ActivityInformationCard } from './ActivityInformationCard';
export { DealerInformationCard } from './DealerInformationCard';
export { ProcessDetailsCard } from './ProcessDetailsCard';
export { ProposalDetailsCard } from './ProposalDetailsCard';
export { RequestInitiatorCard } from './RequestInitiatorCard';
export { CloserCard } from './CloserCard';

View File

@ -0,0 +1,237 @@
/**
* AdditionalApproverReviewModal Component
* Modal for Additional Approvers to review request and approve/reject
* Similar to InitiatorProposalApprovalModal but simpler - shows request details
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
CheckCircle,
XCircle,
FileText,
MessageSquare,
} from 'lucide-react';
import { toast } from 'sonner';
import { FormattedDescription } from '@/components/common/FormattedDescription';
interface AdditionalApproverReviewModalProps {
isOpen: boolean;
onClose: () => void;
onApprove: (comments: string) => Promise<void>;
onReject: (comments: string) => Promise<void>;
requestTitle?: string;
requestDescription?: string;
requestId?: string;
levelName?: string;
approverName?: string;
}
export function AdditionalApproverReviewModal({
isOpen,
onClose,
onApprove,
onReject,
requestTitle = 'Request',
requestDescription = '',
requestId,
levelName = 'Approval Level',
approverName = 'Approver',
}: AdditionalApproverReviewModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
const handleApprove = async () => {
if (!comments.trim()) {
toast.error('Please provide approval comments');
return;
}
try {
setSubmitting(true);
setActionType('approve');
await onApprove(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to approve request:', error);
toast.error('Failed to approve request. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleReject = async () => {
if (!comments.trim()) {
toast.error('Please provide rejection reason');
return;
}
try {
setSubmitting(true);
setActionType('reject');
await onReject(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to reject request:', error);
toast.error('Failed to reject request. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleReset = () => {
setComments('');
setActionType(null);
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
if (!isOpen) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col max-w-3xl">
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl">
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
Review Request
</DialogTitle>
<DialogDescription className="text-xs lg:text-sm">
{levelName}: Review request details and make a decision
</DialogDescription>
<div className="space-y-1 mt-2 text-xs text-gray-600">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<div>
<strong>Request ID:</strong> {requestId || 'N/A'}
</div>
<div>
<strong>Approver:</strong> {approverName}
</div>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
<div className="space-y-4">
{/* Request Title */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-600" />
Request Title
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
<p className="text-sm lg:text-base font-medium text-gray-900">{requestTitle}</p>
</div>
</div>
{/* Request Description */}
{requestDescription && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-blue-600" />
Request Description
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[200px] overflow-y-auto">
<FormattedDescription
content={requestDescription}
className="text-xs lg:text-sm text-gray-700"
/>
</div>
</div>
)}
{/* Decision Section */}
<div className="space-y-2 border-t pt-3 lg:pt-3">
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
<Textarea
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[80px] lg:min-h-[90px] text-xs lg:text-sm"
/>
<p className="text-xs text-gray-500">{comments.length} characters</p>
</div>
{/* Warning for missing comments */}
{!comments.trim() && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2">
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p>
</div>
)}
</div>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-3 lg:pt-4 flex-shrink-0 border-t bg-gray-50">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="border-2"
>
Cancel
</Button>
<div className="flex gap-2">
<Button
onClick={handleReject}
disabled={!comments.trim() || submitting}
variant="destructive"
className="bg-red-600 hover:bg-red-700"
>
{submitting && actionType === 'reject' ? (
'Rejecting...'
) : (
<>
<XCircle className="w-4 h-4 mr-2" />
Reject
</>
)}
</Button>
<Button
onClick={handleApprove}
disabled={!comments.trim() || submitting}
className="bg-green-600 hover:bg-green-700 text-white"
>
{submitting && actionType === 'approve' ? (
'Approving...'
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Approve
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,293 @@
/**
* CreditNoteSAPModal Component
* Modal for Step 8: Credit Note from SAP
* Allows Finance team to review credit note details and send to dealer
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Receipt, CircleCheckBig, Hash, Calendar, DollarSign, Building, FileText, Download, Send } from 'lucide-react';
import { toast } from 'sonner';
import { formatDateTime } from '@/utils/dateFormatter';
interface CreditNoteSAPModalProps {
isOpen: boolean;
onClose: () => void;
onDownload?: () => Promise<void>;
onSendToDealer?: () => Promise<void>;
creditNoteData?: {
creditNoteNumber?: string;
creditNoteDate?: string;
creditNoteAmount?: number;
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT' | 'CONFIRMED';
};
dealerInfo?: {
dealerName?: string;
dealerCode?: string;
dealerEmail?: string;
};
activityName?: string;
requestNumber?: string;
requestId?: string;
dueDate?: string;
}
export function CreditNoteSAPModal({
isOpen,
onClose,
onDownload,
onSendToDealer,
creditNoteData,
dealerInfo,
activityName,
requestNumber,
requestId: _requestId,
dueDate,
}: CreditNoteSAPModalProps) {
const [downloading, setDownloading] = useState(false);
const [sending, setSending] = useState(false);
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
const creditNoteDate = creditNoteData?.creditNoteDate
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
: '';
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
const status = creditNoteData?.status || 'PENDING';
const dealerName = dealerInfo?.dealerName || 'Jaipur Royal Enfield';
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
const activity = activityName || 'Activity';
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
const dueDateDisplay = dueDate
? formatDateTime(dueDate, { includeTime: false, format: 'short' })
: 'Jan 4, 2026';
const handleDownload = async () => {
if (onDownload) {
try {
setDownloading(true);
await onDownload();
toast.success('Credit note downloaded successfully');
} catch (error) {
console.error('Failed to download credit note:', error);
toast.error('Failed to download credit note. Please try again.');
} finally {
setDownloading(false);
}
} else {
// Default behavior: show info message
toast.info('Credit note will be automatically saved to Documents tab');
}
};
const handleSendToDealer = async () => {
if (onSendToDealer) {
try {
setSending(true);
await onSendToDealer();
toast.success('Credit note sent to dealer successfully');
onClose();
} catch (error) {
console.error('Failed to send credit note to dealer:', error);
toast.error('Failed to send credit note. Please try again.');
} finally {
setSending(false);
}
} else {
// Default behavior: show info message
toast.info('Email notification will be sent to dealer with credit note attachment');
}
};
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg lg:max-w-[1000px] max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
<Receipt className="w-6 h-6 text-[--re-green]" />
Credit Note from SAP
</DialogTitle>
<DialogDescription className="text-base">
Review and send credit note to dealer
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-4">
{hasCreditNote ? (
<>
{/* Credit Note Document Card */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-green-900 text-xl mb-1">Royal Enfield</h3>
<p className="text-sm text-green-700">Credit Note Document</p>
</div>
<Badge className="bg-green-600 text-white px-4 py-2 text-base">
<CircleCheckBig className="w-4 h-4 mr-2" />
{status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="bg-white rounded-lg p-3 border border-green-100">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1">
<Hash className="w-3 h-3" />
Credit Note Number
</Label>
<p className="font-bold text-gray-900 mt-1 text-lg">{creditNoteNumber}</p>
</div>
<div className="bg-white rounded-lg p-3 border border-green-100">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1">
<Calendar className="w-3 h-3" />
Issue Date
</Label>
<p className="font-semibold text-gray-900 mt-1">{creditNoteDate}</p>
</div>
</div>
</div>
{/* Credit Note Amount */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-5">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1 mb-3">
<DollarSign className="w-4 h-4" />
Credit Note Amount
</Label>
<p className="text-4xl font-bold text-blue-700">{formatCurrency(creditNoteAmount)}</p>
</div>
</>
) : (
/* No Credit Note Available */
<div className="bg-gray-50 border-2 border-gray-300 rounded-lg p-8 text-center">
<div className="flex flex-col items-center justify-center space-y-4">
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center">
<Receipt className="w-8 h-8 text-gray-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-2">No Credit Note Available</h3>
<p className="text-sm text-gray-500">
Credit note has not been generated yet. Please wait for the credit note to be generated from DMS.
</p>
</div>
</div>
</div>
)}
{/* Dealer Information */}
<div className="bg-purple-50 border-2 border-purple-200 rounded-lg p-5">
<h3 className="font-semibold text-purple-900 mb-4 flex items-center gap-2">
<Building className="w-5 h-5" />
Dealer Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-lg p-3 border border-purple-100">
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
Dealer Name
</Label>
<p className="font-semibold text-gray-900 mt-1">{dealerName}</p>
</div>
<div className="bg-white rounded-lg p-3 border border-purple-100">
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
Dealer Code
</Label>
<p className="font-semibold text-gray-900 mt-1">{dealerCode}</p>
</div>
<div className="bg-white rounded-lg p-3 border border-purple-100">
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
Activity
</Label>
<p className="font-semibold text-gray-900 mt-1">{activity}</p>
</div>
</div>
</div>
{/* Reference Details */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
<FileText className="w-4 h-4" />
Reference Details
</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600">
Request ID
</Label>
<p className="font-medium text-gray-900 mt-1">{requestIdDisplay}</p>
</div>
<div>
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600">
Due Date
</Label>
<p className="font-medium text-gray-900 mt-1">{dueDateDisplay}</p>
</div>
</div>
</div>
{/* Available Actions Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<FileText className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-semibold mb-2">Available Actions</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>
<strong>Download:</strong> Credit note will be automatically saved to Documents tab
</li>
<li>
<strong>Send to Dealer:</strong> Email notification will be sent to dealer with credit note attachment
</li>
<li>All actions will be recorded in activity trail for audit purposes</li>
</ul>
</div>
</div>
</div>
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row flex items-center justify-between sm:justify-between">
<Button
variant="outline"
onClick={onClose}
disabled={downloading || sending}
className="border-2"
>
Close
</Button>
<div className="flex gap-2">
{hasCreditNote && (
<>
<Button
variant="outline"
onClick={handleDownload}
disabled={downloading || sending}
className="border-blue-600 text-blue-600 hover:bg-blue-50"
>
<Download className="w-4 h-4 mr-2" />
{downloading ? 'Downloading...' : 'Download'}
</Button>
<Button
onClick={handleSendToDealer}
disabled={downloading || sending}
className="bg-green-600 hover:bg-green-700 text-white shadow-md"
>
<Send className="w-4 h-4 mr-2" />
{sending ? 'Sending...' : 'Send to Dealer'}
</Button>
</>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,39 @@
.dms-push-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dms-push-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.dms-push-modal {
width: 90vw !important;
max-width: 90vw !important;
}
}
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dms-push-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}
/* Extra large screens */
@media (min-width: 1536px) {
.dms-push-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}

View File

@ -0,0 +1,872 @@
/**
* DMSPushModal Component
* Modal for Step 6: Push to DMS Verification
* Allows user to verify completion details and expenses before pushing to DMS
*/
import { useState, useMemo, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Receipt,
DollarSign,
TriangleAlert,
Activity,
CheckCircle2,
Download,
Eye,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
import '@/components/common/FilePreview/FilePreview.css';
import './DMSPushModal.css';
interface ExpenseItem {
description: string;
amount: number;
}
interface CompletionDetails {
activityCompletionDate?: string;
numberOfParticipants?: number;
closedExpenses?: ExpenseItem[];
totalClosedExpenses?: number;
completionDescription?: string;
}
interface IODetails {
ioNumber?: string;
blockedAmount?: number;
availableBalance?: number;
remainingBalance?: number;
}
interface CompletionDocuments {
completionDocuments?: Array<{
name: string;
url?: string;
id?: string;
}>;
activityPhotos?: Array<{
name: string;
url?: string;
id?: string;
}>;
invoicesReceipts?: Array<{
name: string;
url?: string;
id?: string;
}>;
attendanceSheet?: {
name: string;
url?: string;
id?: string;
};
}
interface DMSPushModalProps {
isOpen: boolean;
onClose: () => void;
onPush: (comments: string) => Promise<void>;
completionDetails?: CompletionDetails | null;
ioDetails?: IODetails | null;
completionDocuments?: CompletionDocuments | null;
requestTitle?: string;
requestNumber?: string;
}
export function DMSPushModal({
isOpen,
onClose,
onPush,
completionDetails,
ioDetails,
completionDocuments,
requestTitle,
requestNumber,
}: DMSPushModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [previewDocument, setPreviewDocument] = useState<{
name: string;
url: string;
type?: string;
size?: number;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const commentsChars = comments.length;
const maxCommentsChars = 500;
// Calculate total closed expenses
const totalClosedExpenses = useMemo(() => {
if (completionDetails?.totalClosedExpenses) {
return completionDetails.totalClosedExpenses;
}
if (completionDetails?.closedExpenses && Array.isArray(completionDetails.closedExpenses)) {
return completionDetails.closedExpenses.reduce((sum, item) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
return sum + (Number(amount) || 0);
}, 0);
}
return 0;
}, [completionDetails]);
// Format date
const formatDate = (dateString?: string) => {
if (!dateString) return '—';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-IN', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
} catch {
return dateString;
}
};
// Format currency
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
// Check if document can be previewed
const canPreviewDocument = (doc: { name: string; url?: string; id?: string }): boolean => {
if (!doc.name) return false;
const name = doc.name.toLowerCase();
return name.endsWith('.pdf') ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg') ||
name.endsWith('.png') ||
name.endsWith('.gif') ||
name.endsWith('.webp');
};
// Handle document preview - fetch as blob to avoid CSP issues
const handlePreviewDocument = async (doc: { name: string; url?: string; id?: string }) => {
if (!doc.id) {
toast.error('Document preview not available - document ID missing');
return;
}
setPreviewLoading(true);
try {
const previewUrl = getDocumentPreviewUrl(doc.id);
// Determine file type from name
const fileName = doc.name.toLowerCase();
const isPDF = fileName.endsWith('.pdf');
const isImage = fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i);
// Fetch document as a blob to create a blob URL (CSP compliant)
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
const token = isProduction ? null : localStorage.getItem('accessToken');
const headers: HeadersInit = {
'Accept': isPDF ? 'application/pdf' : '*/*'
};
if (!isProduction && token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(previewUrl, {
headers,
credentials: 'include',
mode: 'cors'
});
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
if (blob.size === 0) {
throw new Error('File is empty or could not be loaded');
}
// Create blob URL (CSP compliant - uses 'blob:' protocol)
const blobUrl = window.URL.createObjectURL(blob);
setPreviewDocument({
name: doc.name,
url: blobUrl,
type: blob.type || (isPDF ? 'application/pdf' : isImage ? 'image' : undefined),
size: blob.size,
});
} catch (error) {
console.error('Failed to load document preview:', error);
toast.error('Failed to load document preview');
} finally {
setPreviewLoading(false);
}
};
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
if (previewDocument?.url && previewDocument.url.startsWith('blob:')) {
window.URL.revokeObjectURL(previewDocument.url);
}
};
}, [previewDocument]);
const handleSubmit = async () => {
if (!comments.trim()) {
toast.error('Please provide comments before pushing to DMS');
return;
}
try {
setSubmitting(true);
await onPush(comments.trim());
handleReset();
onClose();
} catch (error) {
console.error('Failed to push to DMS:', error);
toast.error('Failed to push to DMS. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setComments('');
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dms-push-modal overflow-hidden flex flex-col">
<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="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" />
</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 */}
<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">
<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>
</div>
{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">
<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 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="space-y-3 sm:space-y-4 max-w-7xl mx-auto">
{/* 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">
{/* Completion Details Card */}
{completionDetails && (
<Card>
<CardHeader className="pb-3">
<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" />
Completion Details
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Review activity completion information
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 sm:space-y-3">
{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">
<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">
{formatDate(completionDetails.activityCompletionDate)}
</span>
</div>
)}
{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">
<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">
{completionDetails.numberOfParticipants}
</span>
</div>
)}
{completionDetails.completionDescription && (
<div className="pt-2">
<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">
{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>
</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">
<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>
{/* Comments & Remarks */}
<div className="space-y-1.5 max-w-2xl">
<Label htmlFor="comment" className="text-xs sm:text-sm font-semibold text-gray-900 flex items-center gap-2">
Comments & Remarks <span className="text-red-500">*</span>
</Label>
<Textarea
id="comment"
placeholder="Enter your comments about pushing to DMS (e.g., verified expenses, ready for invoice generation)..."
value={comments}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxCommentsChars) {
setComments(value);
}
}}
rows={4}
className="text-sm min-h-[80px] resize-none"
/>
<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" />
Required and visible to all
</div>
<span>{commentsChars}/{maxCommentsChars}</span>
</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">
<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 */}
{previewDocument && (
<Dialog
open={!!previewDocument}
onOpenChange={() => setPreviewDocument(null)}
>
<DialogContent className="file-preview-dialog p-3 sm:p-6">
<div className="file-preview-content">
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
{previewDocument.name}
</DialogTitle>
{previewDocument.type && (
<p className="text-xs sm:text-sm text-gray-500">
{previewDocument.type} {previewDocument.size && `${(previewDocument.size / 1024).toFixed(1)} KB`}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap mr-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = previewDocument.url;
link.download = previewDocument.name;
link.click();
}}
className="gap-2 h-9"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Download</span>
</Button>
</div>
</div>
</DialogHeader>
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
{previewLoading ? (
<div className="flex items-center justify-center h-full min-h-[70vh]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mx-auto mb-2" />
<p className="text-sm text-gray-600">Loading preview...</p>
</div>
</div>
) : previewDocument.name.toLowerCase().endsWith('.pdf') || previewDocument.type?.includes('pdf') ? (
<div className="w-full h-full flex items-center justify-center">
<iframe
src={previewDocument.url}
className="w-full h-full rounded-lg border-0"
title={previewDocument.name}
style={{
minHeight: '70vh',
height: '100%'
}}
/>
</div>
) : previewDocument.name.match(/\.(jpg|jpeg|png|gif|webp)$/i) || previewDocument.type?.includes('image') ? (
<div className="flex items-center justify-center h-full">
<img
src={previewDocument.url}
alt={previewDocument.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}}
className="rounded-lg shadow-lg"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
<Eye className="w-10 h-10 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
<p className="text-sm text-gray-600 mb-6">
This file type cannot be previewed. Please download to view.
</p>
<Button
onClick={() => {
const link = document.createElement('a');
link.href = previewDocument.url;
link.download = previewDocument.name;
link.click();
}}
className="gap-2"
>
<Download className="h-4 w-4" />
Download {previewDocument.name}
</Button>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
</Dialog>
);
}

View File

@ -0,0 +1,68 @@
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dealer-completion-documents-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 90vw !important;
}
}
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}
/* Extra large screens */
@media (min-width: 1536px) {
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}
/* Date input calendar icon positioning */
.dealer-completion-documents-modal input[type="date"] {
position: relative;
cursor: pointer;
}
.dealer-completion-documents-modal input[type="date"]::-webkit-calendar-picker-indicator {
position: absolute;
right: 0.5rem;
cursor: pointer;
opacity: 1;
z-index: 1;
pointer-events: auto;
}
.dealer-completion-documents-modal input[type="date"]::-webkit-inner-spin-button,
.dealer-completion-documents-modal input[type="date"]::-webkit-clear-button {
display: none;
-webkit-appearance: none;
}
/* Firefox date input */
.dealer-completion-documents-modal input[type="date"]::-moz-calendar-picker-indicator {
position: absolute;
right: 0.5rem;
cursor: pointer;
opacity: 1;
}

View File

@ -0,0 +1,68 @@
.dealer-proposal-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dealer-proposal-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.dealer-proposal-modal {
width: 90vw !important;
max-width: 90vw !important;
}
}
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dealer-proposal-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}
/* Extra large screens */
@media (min-width: 1536px) {
.dealer-proposal-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}
/* Date input calendar icon positioning */
.dealer-proposal-modal input[type="date"] {
position: relative;
cursor: pointer;
}
.dealer-proposal-modal input[type="date"]::-webkit-calendar-picker-indicator {
position: absolute;
right: 0.5rem;
cursor: pointer;
opacity: 1;
z-index: 1;
pointer-events: auto;
}
.dealer-proposal-modal input[type="date"]::-webkit-inner-spin-button,
.dealer-proposal-modal input[type="date"]::-webkit-clear-button {
display: none;
-webkit-appearance: none;
}
/* Firefox date input */
.dealer-proposal-modal input[type="date"]::-moz-calendar-picker-indicator {
position: absolute;
right: 0.5rem;
cursor: pointer;
opacity: 1;
}

View File

@ -0,0 +1,914 @@
/**
* DealerProposalSubmissionModal Component
* Modal for Step 1: Dealer Proposal Submission
* Allows dealers to upload proposal documents, provide cost breakdown, timeline, and comments
*/
import { useState, useRef, useMemo, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { CustomDatePicker } from '@/components/ui/date-picker';
import { Upload, Plus, Minus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react';
import { toast } from 'sonner';
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
import '@/components/common/FilePreview/FilePreview.css';
import './DealerProposalModal.css';
interface CostItem {
id: string;
description: string;
amount: number;
}
interface DealerProposalSubmissionModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: {
proposalDocument: File | null;
costBreakup: CostItem[];
expectedCompletionDate: string;
otherDocuments: File[];
dealerComments: string;
}) => Promise<void>;
dealerName?: string;
activityName?: string;
requestId?: string;
previousProposalData?: any;
documentPolicy: {
maxFileSizeMB: number;
allowedFileTypes: string[];
};
}
export function DealerProposalSubmissionModal({
isOpen,
onClose,
onSubmit,
dealerName = 'Jaipur Royal Enfield',
activityName = 'Activity',
requestId: _requestId,
previousProposalData,
documentPolicy,
}: DealerProposalSubmissionModalProps) {
const [proposalDocument, setProposalDocument] = useState<File | null>(null);
const [costItems, setCostItems] = useState<CostItem[]>([
{ id: '1', description: '', amount: 0 },
]);
const [timelineMode, setTimelineMode] = useState<'date' | 'days'>('date');
const [expectedCompletionDate, setExpectedCompletionDate] = useState('');
const [numberOfDays, setNumberOfDays] = useState('');
const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
const [dealerComments, setDealerComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [previewDoc, setPreviewDoc] = useState<{
fileName: string;
fileType: string;
documentId: string;
fileUrl?: string;
fileSize?: number;
} | null>(null);
const [showPreviousProposal, setShowPreviousProposal] = useState(false);
const proposalDocInputRef = useRef<HTMLInputElement>(null);
const otherDocsInputRef = useRef<HTMLInputElement>(null);
// Helper function to check if file can be previewed
const canPreview = (fileName: string): boolean => {
if (!fileName) return false;
const name = fileName.toLowerCase();
return name.endsWith('.pdf') ||
!!name.match(/\.(jpg|jpeg|png|gif|webp)$/i);
};
const canPreviewFile = (file: File): boolean => {
return canPreview(file.name);
};
// Cleanup object URLs when component unmounts or file changes
useEffect(() => {
return () => {
if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewDoc.fileUrl);
}
};
}, [previewDoc]);
// Handle manual file preview (for local files)
const handlePreviewFile = (file: File) => {
if (!canPreviewFile(file)) {
toast.error('Preview is only available for images and PDF files');
return;
}
// Cleanup previous preview URL if it was a blob
if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewDoc.fileUrl);
}
// Create blob URL for local file
const url = URL.createObjectURL(file);
setPreviewDoc({
fileName: file.name,
fileType: file.type,
documentId: '',
fileUrl: url,
fileSize: file.size
});
};
// Handle preview for existing Documents (with storageUrl/documentId)
const handlePreviewExisting = (doc: any) => {
const fileName = doc.originalFileName || doc.fileName || doc.name || 'Document';
const documentId = doc.documentId || doc.id || '';
const fileType = fileName.toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';
let fileUrl = '';
if (documentId) {
fileUrl = getDocumentPreviewUrl(documentId);
} else {
fileUrl = doc.storageUrl || doc.documentUrl || '';
if (fileUrl && !fileUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
}
setPreviewDoc({
fileName,
fileType,
documentId,
fileUrl
});
};
// Handle download file (for non-previewable files)
const handleDownloadFile = (file: File) => {
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Calculate total estimated budget
const totalBudget = useMemo(() => {
return costItems.reduce((sum, item) => sum + (item.amount || 0), 0);
}, [costItems]);
// Check if all required fields are filled
const isFormValid = useMemo(() => {
const hasProposalDoc = proposalDocument !== null;
const hasValidCostItems = costItems.length > 0 &&
costItems.every(item => item.description.trim() !== '' && item.amount > 0);
const hasTimeline = timelineMode === 'date'
? expectedCompletionDate !== ''
: numberOfDays !== '' && parseInt(numberOfDays) > 0;
const hasComments = dealerComments.trim().length > 0;
return hasProposalDoc && hasValidCostItems && hasTimeline && hasComments;
}, [proposalDocument, costItems, timelineMode, expectedCompletionDate, numberOfDays, dealerComments]);
const handleProposalDocChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 1. Check file size
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
if (file.size > maxSizeBytes) {
toast.error(`File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`);
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
return;
}
// 2. Validate file type (User requested: Keep strictly to pdf, doc, docx + Intersection with system policy)
const hardcodedAllowed = ['.pdf', '.doc', '.docx'];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
const simpleExt = file.name.split('.').pop()?.toLowerCase() || '';
if (!hardcodedAllowed.includes(fileExtension) || !documentPolicy.allowedFileTypes.includes(simpleExt)) {
toast.error('Please upload a valid PDF, DOC, or DOCX file as per system policy');
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
return;
}
setProposalDocument(file);
}
};
const handleOtherDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const validFiles: File[] = [];
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
files.forEach(file => {
// 1. Check file size
if (file.size > maxSizeBytes) {
toast.error(`"${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit and was not added.`);
return;
}
// 2. Check file type
const fileExtension = file.name.split('.').pop()?.toLowerCase() || '';
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
toast.error(`"${file.name}" has an unsupported file type and was not added.`);
return;
}
validFiles.push(file);
});
if (validFiles.length > 0) {
setOtherDocuments(prev => [...prev, ...validFiles]);
}
// Reset input so searching the same file again triggers change event
if (otherDocsInputRef.current) otherDocsInputRef.current.value = '';
};
const handleAddCostItem = () => {
setCostItems(prev => [
...prev,
{ id: Date.now().toString(), description: '', amount: 0 },
]);
};
const handleRemoveCostItem = (id: string) => {
if (costItems.length > 1) {
setCostItems(prev => prev.filter(item => item.id !== id));
}
};
const handleCostItemChange = (id: string, field: 'description' | 'amount', value: string | number) => {
setCostItems(prev =>
prev.map(item =>
item.id === id
? { ...item, [field]: field === 'amount' ? parseFloat(value.toString()) || 0 : value }
: item
)
);
};
const handleRemoveOtherDoc = (index: number) => {
setOtherDocuments(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
if (!isFormValid) {
toast.error('Please fill all required fields');
return;
}
// Calculate final completion date if using days mode
let finalCompletionDate: string = expectedCompletionDate || '';
if (timelineMode === 'days' && numberOfDays) {
const days = parseInt(numberOfDays);
const date = new Date();
date.setDate(date.getDate() + days);
const isoString = date.toISOString();
finalCompletionDate = isoString.split('T')[0] as string;
}
try {
setSubmitting(true);
await onSubmit({
proposalDocument,
costBreakup: costItems.filter(item => item.description.trim() !== '' && item.amount > 0),
expectedCompletionDate: finalCompletionDate,
otherDocuments,
dealerComments,
});
handleReset();
onClose();
} catch (error) {
console.error('Failed to submit proposal:', error);
toast.error('Failed to submit proposal. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
// Cleanup preview URL if exists and it's a blob
if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewDoc.fileUrl);
}
setPreviewDoc(null);
setProposalDocument(null);
setCostItems([{ id: '1', description: '', amount: 0 }]);
setTimelineMode('date');
setExpectedCompletionDate('');
setNumberOfDays('');
setOtherDocuments([]);
setDealerComments('');
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
if (otherDocsInputRef.current) otherDocsInputRef.current.value = '';
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
// Get minimum date (today)
const minDate = new Date().toISOString().split('T')[0];
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4">
<DialogTitle className="flex items-center gap-2 text-xl lg:text-2xl">
<Upload className="w-5 h-5 lg:w-6 lg:h-6 text-[--re-green]" />
Dealer Proposal Submission
</DialogTitle>
<DialogDescription className="text-sm lg:text-base">
Step 1: Upload proposal and planning details
</DialogDescription>
<div className="space-y-1 mt-2 text-xs lg:text-sm text-gray-600">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
</div>
<div className="mt-1">
Please upload proposal document, provide cost breakdown, timeline, and detailed comments.
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4">
{/* Previous Proposal Reference Section */}
{previousProposalData && (
<div className="mb-6 mx-1">
<div
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
onClick={() => setShowPreviousProposal(!showPreviousProposal)}
>
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-amber-700" />
<span className="text-sm font-semibold text-amber-900">Reference: Previous Proposal Details</span>
<Badge variant="secondary" className="bg-amber-200 text-amber-800 text-[10px]">
{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
</Badge>
</div>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-amber-700">
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
</Button>
</div>
{showPreviousProposal && (
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
{/* Header Info: Date & Document */}
<div className="flex flex-wrap gap-4 text-xs">
{previousProposalData.expectedCompletionDate && (
<div className="flex items-center gap-1.5 text-gray-700">
<Calendar className="w-3.5 h-3.5 text-gray-500" />
<span className="font-medium">Expected Completion:</span>
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
</div>
)}
{previousProposalData.documentUrl && (
<div className="flex items-center gap-1.5">
{canPreview(previousProposalData.documentUrl) ? (
<>
<Eye className="w-3.5 h-3.5 text-blue-500" />
<a
href={previousProposalData.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
>
View Previous Document
</a>
</>
) : (
<>
<Download className="w-3.5 h-3.5 text-blue-500" />
<a
href={previousProposalData.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
>
Download Previous Document
</a>
</>
)}
</div>
)}
{/* Additional/Supporting Documents */}
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
<div className="w-full pt-2 border-t border-amber-200/50">
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
<FileText className="w-3 h-3" />
Supporting Documents
</p>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard
key={idx}
document={{
documentId: doc.documentId || doc.id || '',
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
uploadedAt: doc.uploadedAt || new Date().toISOString()
}}
onPreview={canPreview(doc.originalFileName || doc.fileName || doc.name || '') ? () => handlePreviewExisting(doc) : undefined}
onDownload={async (id) => {
if (id) {
await downloadDocument(id);
} else {
let downloadUrl = doc.storageUrl || doc.documentUrl;
if (downloadUrl && !downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
if (downloadUrl) window.open(downloadUrl, '_blank');
}
}}
/>
))}
</div>
</div>
)}
</div>
{/* Previous Cost Breakup (handling both costBreakup and costItems) */}
{(previousProposalData.costItems || previousProposalData.costBreakup) && (previousProposalData.costItems || previousProposalData.costBreakup).length > 0 && (
<div className="mt-2">
<p className="text-xs font-semibold text-gray-700 mb-2">Previous Cost Breakdown:</p>
<div className="border rounded-md overflow-hidden text-xs">
<table className="w-full text-left">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="p-2 font-medium">Description</th>
<th className="p-2 font-medium text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y">
{(previousProposalData.costItems || previousProposalData.costBreakup).map((item: any, idx: number) => (
<tr key={idx} className="bg-white">
<td className="p-2 text-gray-800">{item.description}</td>
<td className="p-2 text-right text-gray-800 font-medium">
{Number(item.amount).toLocaleString('en-IN')}
</td>
</tr>
))}
<tr className="bg-gray-50 font-bold">
<td className="p-2 text-gray-900">Total</td>
<td className="p-2 text-right text-gray-900">
{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* Previous Comments */}
{(previousProposalData.comments || previousProposalData.dealerComments) && (
<div>
<p className="text-xs font-semibold text-gray-700 mb-1">Previous Comments:</p>
<div className="bg-white border rounded p-2 text-xs text-gray-600 italic">
"{previousProposalData.comments || previousProposalData.dealerComments}"
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
{/* Left Column - Documents */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Proposal Document Section */}
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base lg:text-lg">Proposal Document</h3>
<Badge variant="outline" className="text-xs border-red-500 text-red-700 bg-red-50 font-medium">Required</Badge>
</div>
<div>
<Label className="text-sm lg:text-base font-semibold flex items-center gap-2">
Proposal Document *
</Label>
<p className="text-xs lg:text-sm text-gray-600 mb-2">
Detailed proposal with activity details and requested information
</p>
<div
className={`border-2 border-dashed rounded-lg p-3 lg:p-4 transition-all duration-200 ${
proposalDocument
? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={proposalDocInputRef}
type="file"
accept={['.pdf', '.doc', '.docx'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
className="hidden"
id="proposalDoc"
onChange={handleProposalDocChange}
/>
<label
htmlFor="proposalDoc"
className="cursor-pointer flex flex-col items-center gap-2"
>
{proposalDocument ? (
<div className="flex flex-col items-center gap-2 w-full">
<CheckCircle2 className="w-8 h-8 text-green-600" />
<div className="flex flex-col items-center gap-1 w-full max-w-full px-2">
<span className="text-sm font-semibold text-green-700 break-words text-center w-full max-w-full">
{proposalDocument.name}
</span>
<span className="text-xs text-green-600 mb-2">
Document selected
</span>
</div>
<div className="flex items-center gap-2">
{canPreviewFile(proposalDocument) && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handlePreviewFile(proposalDocument)}
className="h-8 text-xs"
>
<Eye className="w-3.5 h-3.5 mr-1" />
Preview
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleDownloadFile(proposalDocument)}
className="h-8 text-xs"
>
<Download className="w-3.5 h-3.5 mr-1" />
Download
</Button>
</div>
</div>
) : (
<>
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
Click to upload proposal (PDF, DOC, DOCX)
</span>
</>
)}
</label>
</div>
</div>
</div>
{/* Other Supporting Documents Section */}
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base lg:text-lg">Other Supporting Documents</h3>
<Badge variant="outline" className="text-xs border-gray-300 text-gray-600 bg-gray-50 font-medium">Optional</Badge>
</div>
<div>
<Label className="flex items-center gap-2 text-sm lg:text-base font-semibold">
Additional Documents
</Label>
<p className="text-xs lg:text-sm text-gray-600 mb-2">
Any other supporting documents (invoices, receipts, photos, etc.)
</p>
<div
className={`border-2 border-dashed rounded-lg p-3 lg:p-4 transition-all duration-200 ${
otherDocuments.length > 0
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={otherDocsInputRef}
type="file"
multiple
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
className="hidden"
id="otherDocs"
onChange={handleOtherDocsChange}
/>
<label
htmlFor="otherDocs"
className="cursor-pointer flex flex-col items-center gap-2"
>
{otherDocuments.length > 0 ? (
<>
<CheckCircle2 className="w-8 h-8 text-blue-600" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-semibold text-blue-700">
{otherDocuments.length} document{otherDocuments.length !== 1 ? 's' : ''} selected
</span>
<span className="text-xs text-blue-600">
Click to add more documents
</span>
</div>
</>
) : (
<>
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600 text-center">
Click to upload additional documents
</span>
<span className="text-[10px] text-gray-400">
Max {documentPolicy.maxFileSizeMB}MB | {documentPolicy.allowedFileTypes.join(', ').toUpperCase()}
</span>
</>
)}
</label>
</div>
{otherDocuments.length > 0 && (
<div className="mt-2 lg:mt-3 space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
<p className="text-xs font-medium text-gray-600 mb-1">
Selected Documents ({otherDocuments.length}):
</p>
{otherDocuments.map((file, index) => (
<div
key={index}
className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-2 lg:p-3 rounded-lg text-xs lg:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
>
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">
{file.name}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
{canPreviewFile(file) && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
onClick={() => handlePreviewFile(file)}
title="Preview file"
>
<Eye className="w-3.5 h-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
onClick={() => handleDownloadFile(file)}
title="Download file"
>
<Download className="w-3.5 h-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveOtherDoc(index)}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Right Column - Planning & Budget */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Cost Breakup Section */}
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base lg:text-lg">Cost Breakup</h3>
<Badge variant="outline" className="text-xs border-red-500 text-red-700 bg-red-50 font-medium">Required</Badge>
</div>
<Button
type="button"
onClick={handleAddCostItem}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
size="sm"
>
<Plus className="w-3 h-3 lg:w-4 lg:h-4 mr-1" />
<span className="hidden sm:inline">Add Item</span>
<span className="sm:hidden">Add</span>
</Button>
</div>
<div className="space-y-2 lg:space-y-2 max-h-[200px] lg:max-h-[180px] overflow-y-auto">
{costItems.map((item) => (
<div key={item.id} className="flex gap-2 items-start w-full">
<div className="flex-1 min-w-0">
<Input
placeholder="Item description (e.g., Banner printing, Event setup)"
value={item.description}
onChange={(e) =>
handleCostItemChange(item.id, 'description', e.target.value)
}
className="w-full"
/>
</div>
<div className="w-32 lg:w-36 flex-shrink-0">
<Input
type="number"
placeholder="Amount"
min="0"
step="0.01"
value={item.amount || ''}
onChange={(e) =>
handleCostItemChange(item.id, 'amount', e.target.value)
}
className="w-full"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="mt-0.5 hover:bg-red-100 hover:text-red-700 flex-shrink-0"
onClick={() => handleRemoveCostItem(item.id)}
disabled={costItems.length === 1}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
<div className="border-2 border-gray-300 rounded-lg p-3 lg:p-4 bg-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IndianRupee className="w-4 h-4 lg:w-5 lg:h-5 text-gray-700" />
<span className="font-semibold text-sm lg:text-base text-gray-900">Estimated Budget</span>
</div>
<div className="text-xl lg:text-2xl font-bold text-gray-900">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</div>
{/* Timeline for Closure Section */}
<div className="space-y-2 lg:space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base lg:text-lg">Timeline for Closure</h3>
<Badge variant="outline" className="text-xs border-red-500 text-red-700 bg-red-50 font-medium">Required</Badge>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Button
type="button"
onClick={() => setTimelineMode('date')}
className={
timelineMode === 'date'
? 'bg-[#2d4a3e] hover:bg-[#1f3329] text-white'
: 'border-2 hover:bg-gray-50'
}
size="sm"
>
<Calendar className="w-4 h-4 mr-1" />
Specific Date
</Button>
<Button
type="button"
onClick={() => setTimelineMode('days')}
className={
timelineMode === 'days'
? 'bg-[#2d4a3e] hover:bg-[#1f3329] text-white'
: 'border-2 hover:bg-gray-50'
}
size="sm"
>
Number of Days
</Button>
</div>
{timelineMode === 'date' ? (
<div className="w-full">
<Label className="text-xs lg:text-sm font-medium mb-1.5 lg:mb-2 block">
Expected Completion Date
</Label>
<CustomDatePicker
value={expectedCompletionDate || null}
onChange={(date) => setExpectedCompletionDate(date || '')}
minDate={minDate}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
) : (
<div className="w-full">
<Label className="text-xs lg:text-sm font-medium mb-1.5 lg:mb-2 block">
Number of Days
</Label>
<Input
type="number"
placeholder="Enter number of days"
min="1"
value={numberOfDays}
onChange={(e) => setNumberOfDays(e.target.value)}
className="h-9 lg:h-10 w-full"
/>
</div>
)}
</div>
</div>
{/* Dealer Comments Section */}
<div className="space-y-2">
<Label htmlFor="dealerComments" className="text-sm lg:text-base font-semibold flex items-center gap-2">
Dealer Comments / Details *
</Label>
<Textarea
id="dealerComments"
placeholder="Provide detailed comments about this claim request, including any special considerations, execution details, or additional information..."
value={dealerComments}
onChange={(e) => setDealerComments(e.target.value)}
className="min-h-[80px] lg:min-h-[100px] text-sm w-full"
/>
<p className="text-xs text-gray-500">{dealerComments.length} characters</p>
</div>
</div>
{/* Full Width Sections */}
{/* Warning Message */}
{!isFormValid && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 lg:p-4 flex items-start gap-2 lg:gap-3 lg:col-span-2">
<CircleAlert className="w-4 h-4 lg:w-5 lg:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-xs lg:text-sm text-amber-800">
<p className="font-semibold mb-1">Missing Required Information</p>
<p>
Please ensure proposal document, cost breakup, timeline, and dealer comments are provided before submitting.
</p>
</div>
</div>
)}
</div>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end flex-shrink-0 pt-3 lg:pt-4 border-t">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="border-2"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || submitting}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white disabled:bg-gray-300 disabled:text-gray-500"
>
{submitting ? 'Submitting...' : 'Submit Documents'}
</Button>
</DialogFooter>
</DialogContent>
{/* Standardized File Preview */}
{previewDoc && (
<FilePreview
fileName={previewDoc.fileName}
fileType={previewDoc.fileType}
fileUrl={previewDoc.fileUrl}
fileSize={previewDoc.fileSize}
attachmentId={previewDoc.documentId}
onDownload={downloadDocument}
open={!!previewDoc}
onClose={() => setPreviewDoc(null)}
/>
)}
</Dialog>
);
}

View File

@ -0,0 +1,39 @@
.dept-lead-io-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dept-lead-io-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.dept-lead-io-modal {
width: 90vw !important;
max-width: 90vw !important;
}
}
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dept-lead-io-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}
/* Extra large screens */
@media (min-width: 1536px) {
.dept-lead-io-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}

View File

@ -0,0 +1,331 @@
/**
* DeptLeadIOApprovalModal Component
* Modal for Step 3: Dept Lead Approval and IO Organization
* Allows department lead to approve request and organize IO details
*/
import { useState, useMemo } from 'react';
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react';
import { toast } from 'sonner';
import './DeptLeadIOApprovalModal.css';
interface DeptLeadIOApprovalModalProps {
isOpen: boolean;
onClose: () => void;
onApprove: (data: {
ioNumber: string;
comments: string;
}) => Promise<void>;
onReject: (comments: string) => Promise<void>;
requestTitle?: string;
requestId?: string;
// Pre-filled IO data from IO table
preFilledIONumber?: string;
preFilledBlockedAmount?: number;
preFilledRemainingBalance?: number;
}
export function DeptLeadIOApprovalModal({
isOpen,
onClose,
onApprove,
onReject,
requestTitle,
requestId: _requestId,
preFilledIONumber,
preFilledBlockedAmount,
preFilledRemainingBalance,
}: DeptLeadIOApprovalModalProps) {
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
// Get IO number from props (read-only, from IO table)
const ioNumber = preFilledIONumber || '';
// Reset form when modal opens/closes
React.useEffect(() => {
if (isOpen) {
setComments('');
setActionType('approve');
}
}, [isOpen]);
const commentsChars = comments.length;
const maxCommentsChars = 500;
// Validate form
const isFormValid = useMemo(() => {
if (actionType === 'reject') {
return comments.trim().length > 0;
}
// For approve, need IO number (from table) and comments
return (
ioNumber.trim().length > 0 && // IO number must exist from IO table
comments.trim().length > 0
);
}, [actionType, ioNumber, comments]);
const handleSubmit = async () => {
if (!isFormValid) {
if (actionType === 'approve') {
if (!ioNumber.trim()) {
toast.error('IO number is required. Please block amount from IO tab first.');
return;
}
}
if (!comments.trim()) {
toast.error('Please provide comments');
return;
}
return;
}
try {
setSubmitting(true);
if (actionType === 'approve') {
await onApprove({
ioNumber: ioNumber.trim(),
comments: comments.trim(),
});
} else {
await onReject(comments.trim());
}
handleReset();
onClose();
} catch (error) {
console.error(`Failed to ${actionType} request:`, error);
toast.error(`Failed to ${actionType} request. Please try again.`);
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setActionType('approve');
setComments('');
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dept-lead-io-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 px-6 pt-6 pb-3">
<div className="flex items-center gap-2 lg:gap-3 mb-2">
<div className="p-1.5 lg:p-2 rounded-lg bg-green-100">
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-lg lg:text-xl">
Review and Approve
</DialogTitle>
<DialogDescription className="text-xs lg:text-sm mt-1">
Review IO details and provide your approval comments
</DialogDescription>
</div>
</div>
{/* Request Info Card */}
<div className="space-y-2 lg:space-y-3 p-3 lg:p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<span className="font-medium text-sm lg:text-base text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono text-xs">Step 3</Badge>
</div>
<div>
<span className="font-medium text-sm lg:text-base text-gray-900">Title:</span>
<p className="text-xs lg:text-sm text-gray-700 mt-1">{requestTitle || '—'}</p>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm lg:text-base text-gray-900">Action:</span>
<Badge className="bg-green-100 text-green-800 border-green-200 text-xs">
<CircleCheckBig className="w-3 h-3 mr-1" />
APPROVE
</Badge>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
<div className="space-y-3 lg:space-y-4">
{/* Action Toggle Buttons */}
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<Button
type="button"
onClick={() => setActionType('approve')}
className={`flex-1 text-sm lg:text-base ${
actionType === 'approve'
? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'}
>
<CircleCheckBig className="w-4 h-4 mr-1" />
Approve
</Button>
<Button
type="button"
onClick={() => setActionType('reject')}
className={`flex-1 text-sm lg:text-base ${
actionType === 'reject'
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
>
<CircleX className="w-4 h-4 mr-1" />
Reject
</Button>
</div>
{/* Main Content Area - Two Column Layout on Large Screens */}
<div className="space-y-3 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6">
{/* Left Column - IO Organisation Details (Only shown when approving) */}
{actionType === 'approve' && (
<div className="p-3 lg:p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
<div className="flex items-center gap-2">
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
</div>
{/* IO Number - Read-only from IO table */}
<div className="space-y-1">
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Number <span className="text-red-500">*</span>
</Label>
<Input
id="ioNumber"
value={ioNumber || '—'}
disabled
readOnly
className="bg-gray-100 h-8 lg:h-9 cursor-not-allowed text-xs lg:text-sm"
/>
{!ioNumber && (
<p className="text-xs text-red-600 mt-1">
IO number not found. Please block amount from IO tab first.
</p>
)}
{ioNumber && (
<p className="text-xs text-blue-600 mt-1">
Loaded from IO table
</p>
)}
</div>
{/* IO Balance Information - Read-only */}
<div className="grid grid-cols-2 gap-2">
{/* Blocked Amount Display */}
{preFilledBlockedAmount !== undefined && preFilledBlockedAmount > 0 && (
<div className="p-2 bg-green-50 border border-green-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Blocked Amount:</span>
<span className="text-xs lg:text-sm font-bold text-green-700 mt-1">
{preFilledBlockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
{/* Remaining Balance Display */}
{preFilledRemainingBalance !== undefined && preFilledRemainingBalance !== null && (
<div className="p-2 bg-blue-50 border border-blue-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Remaining Balance:</span>
<span className="text-xs lg:text-sm font-bold text-blue-700 mt-1">
{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
</div>
)}
{/* Right Column - Comments & Remarks */}
<div className={`space-y-1.5 ${actionType === 'approve' ? '' : 'lg:col-span-2'}`}>
<Label htmlFor="comment" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
Comments & Remarks <span className="text-red-500">*</span>
</Label>
<Textarea
id="comment"
placeholder={
actionType === 'approve'
? 'Enter your approval comments and any conditions or notes...'
: 'Enter detailed reasons for rejection...'
}
value={comments}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxCommentsChars) {
setComments(value);
}
}}
rows={4}
className="text-xs lg:text-sm min-h-[80px] lg:min-h-[100px] resize-none"
/>
<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" />
Required and visible to all
</div>
<span>{commentsChars}/{maxCommentsChars}</span>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="flex-shrink-0 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pb-6 pt-3 border-t">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="text-sm lg:text-base"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || submitting}
className={`text-sm lg:text-base ${
actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
} text-white`}
>
{submitting ? (
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`
) : (
<>
<CircleCheckBig className="w-4 h-4 mr-2" />
{actionType === 'approve' ? 'Approve Request' : 'Reject Request'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,194 @@
/**
* EditClaimAmountModal Component
* Modal for editing claim amount (restricted by role)
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { DollarSign, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
interface EditClaimAmountModalProps {
isOpen: boolean;
onClose: () => void;
currentAmount: number;
onSubmit: (newAmount: number) => Promise<void>;
currency?: string;
}
export function EditClaimAmountModal({
isOpen,
onClose,
currentAmount,
onSubmit,
currency = '₹',
}: EditClaimAmountModalProps) {
const [amount, setAmount] = useState<string>(currentAmount.toString());
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string>('');
const formatCurrency = (value: number) => {
return `${currency}${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const handleAmountChange = (value: string) => {
// Remove non-numeric characters except decimal point
const cleaned = value.replace(/[^\d.]/g, '');
// Ensure only one decimal point
const parts = cleaned.split('.');
if (parts.length > 2) {
return;
}
setAmount(cleaned);
setError('');
};
const handleSubmit = async () => {
// Validate amount
const numAmount = parseFloat(amount);
if (isNaN(numAmount) || numAmount <= 0) {
setError('Please enter a valid amount greater than 0');
return;
}
if (numAmount === currentAmount) {
toast.info('Amount is unchanged');
onClose();
return;
}
try {
setSubmitting(true);
await onSubmit(numAmount);
onClose();
} catch (error) {
console.error('Failed to update claim amount:', error);
setError('Failed to update claim amount. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
setAmount(currentAmount.toString());
setError('');
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px] lg:max-w-[800px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-green-600" />
Edit Claim Amount
</DialogTitle>
<DialogDescription>
Update the claim amount. This will be recorded in the activity log.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Current Amount Display */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<Label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1 block">
Current Claim Amount
</Label>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(currentAmount)}
</p>
</div>
{/* New Amount Input */}
<div className="space-y-2">
<Label htmlFor="new-amount" className="text-sm font-medium">
New Claim Amount <span className="text-red-500">*</span>
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 font-semibold">
{currency}
</span>
<Input
id="new-amount"
type="text"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="Enter amount"
className="pl-8 text-lg font-semibold"
disabled={submitting}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600">
<AlertCircle className="w-4 h-4" />
<span>{error}</span>
</div>
)}
</div>
{/* Amount Difference */}
{amount && !isNaN(parseFloat(amount)) && parseFloat(amount) !== currentAmount && (
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-700">Difference:</span>
<span className={`font-semibold ${
parseFloat(amount) > currentAmount ? 'text-green-700' : 'text-red-700'
}`}>
{parseFloat(amount) > currentAmount ? '+' : ''}
{formatCurrency(parseFloat(amount) - currentAmount)}
</span>
</div>
</div>
)}
{/* Warning Message */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-800">
<p className="font-semibold mb-1">Important:</p>
<ul className="space-y-1 list-disc list-inside">
<li>Ensure the new amount is verified and approved</li>
<li>This change will be logged in the activity trail</li>
<li>Budget blocking (IO) may need to be adjusted</li>
</ul>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || !amount || parseFloat(amount) <= 0}
className="bg-green-600 hover:bg-green-700"
>
{submitting ? 'Updating...' : 'Update Amount'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,147 @@
/**
* EmailNotificationTemplateModal Component
* Modal for displaying email notification templates for automated workflow steps
* Used for Step 4: Activity Creation and other auto-triggered steps
*/
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Mail, User, Building, Calendar, X } from 'lucide-react';
interface EmailNotificationTemplateModalProps {
isOpen: boolean;
onClose: () => void;
stepNumber: number;
stepName: string;
requestNumber?: string;
recipientEmail?: string;
subject?: string;
emailBody?: string;
}
export function EmailNotificationTemplateModal({
isOpen,
onClose,
stepNumber,
stepName,
requestNumber = 'RE-REQ-2024-CM-101',
recipientEmail = 'system@royalenfield.com',
subject,
emailBody,
}: EmailNotificationTemplateModalProps) {
// Default subject if not provided
const defaultSubject = `System Notification: Activity Created - ${requestNumber}`;
const finalSubject = subject || defaultSubject;
// Default email body if not provided
const defaultEmailBody = `System Notification
Activity has been automatically created for claim ${requestNumber}.
All stakeholders have been notified.
This is an automated message.`;
const finalEmailBody = emailBody || defaultEmailBody;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-2xl lg:max-w-[1000px] max-w-2xl">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Mail className="w-5 h-5 text-blue-600" />
</div>
<div>
<DialogTitle className="text-lg leading-none font-semibold">
Email Notification Template
</DialogTitle>
<DialogDescription className="text-sm">
Step {stepNumber}: {stepName}
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Email Header Section */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4 border border-blue-200">
<div className="space-y-2">
<div className="flex items-start gap-2">
<User className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-gray-600">To:</p>
<p className="text-sm font-medium text-gray-900">{recipientEmail}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Mail className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-gray-600">Subject:</p>
<p className="text-sm font-semibold text-gray-900">{finalSubject}</p>
</div>
</div>
</div>
</div>
{/* Email Body Section */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="space-y-4">
{/* Company Header */}
<div className="flex items-center gap-2 pb-3 border-b border-gray-200">
<Building className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-gray-900">Royal Enfield</span>
</div>
{/* Email Content */}
<div className="prose prose-sm max-w-none">
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 leading-relaxed bg-transparent p-0 border-0">
{finalEmailBody}
</pre>
</div>
{/* Footer */}
<div className="pt-3 border-t border-gray-200">
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
<span>Automated email Royal Enfield Claims Portal</span>
</div>
</div>
</div>
</div>
{/* Badges */}
<div className="flex items-center gap-2">
<Badge className="bg-blue-50 text-blue-700 border-blue-200">
Step {stepNumber}
</Badge>
<Badge className="bg-purple-50 text-purple-700 border-purple-200">
Auto-triggered
</Badge>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={onClose}
className="h-9"
>
<X className="w-4 h-4 mr-2" />
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,215 @@
/**
* InitiatorActionModal Component
* Modal for Initiator to take action on a returned/rejected request
* Actions: Reopen, Request Revised Quotation, Cancel
*/
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
RefreshCw,
MessageSquare,
FileEdit,
XOctagon,
AlertTriangle,
Loader2
} from 'lucide-react';
import { toast } from 'sonner';
interface InitiatorActionModalProps {
isOpen: boolean;
onClose: () => void;
onAction: (action: 'REOPEN' | 'REVISE' | 'CANCEL', comments: string) => Promise<void>;
requestTitle?: string;
requestId?: string;
defaultAction?: 'REOPEN' | 'REVISE' | 'CANCEL';
}
export function InitiatorActionModal({
isOpen,
onClose,
onAction,
requestTitle = 'Request',
requestId: _requestId,
defaultAction,
}: InitiatorActionModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [selectedAction, setSelectedAction] = useState<'REOPEN' | 'REVISE' | 'CANCEL' | null>(defaultAction || null);
// Update selectedAction when defaultAction changes
useEffect(() => {
if (defaultAction) {
setSelectedAction(defaultAction);
}
}, [defaultAction]);
const actions = [
{
id: 'REOPEN',
label: 'Reopen & Resubmit',
description: 'Resubmit the request to the department head for approval.',
icon: <RefreshCw className="w-5 h-5 text-blue-600" />,
color: 'blue',
variant: 'default' as const
},
{
id: 'REVISE',
label: 'Request Revised Quotation',
description: 'Ask dealer to submit a new proposal/quotation.',
icon: <FileEdit className="w-5 h-5 text-amber-600" />,
color: 'amber',
variant: 'default' as const
},
{
id: 'CANCEL',
label: 'Cancel Request',
description: 'Permanently close and cancel this request.',
icon: <XOctagon className="w-5 h-5 text-red-600" />,
color: 'red',
variant: 'destructive' as const
}
];
const handleActionClick = (actionId: any) => {
setSelectedAction(actionId);
};
const handleSubmit = async () => {
if (!selectedAction) {
toast.error('Please select an action');
return;
}
if (!comments.trim()) {
toast.error('Please provide a reason or comments for this action');
return;
}
try {
setSubmitting(true);
await onAction(selectedAction, comments);
handleReset();
onClose();
} catch (error: any) {
console.error('Failed to perform initiator action:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Action failed. Please try again.';
toast.error(errorMessage);
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setComments('');
setSelectedAction(null);
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
if (!isOpen) return null;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-xl">Action Required: {requestTitle}</DialogTitle>
<DialogDescription>
This request has been returned to you. Please select how you would like to proceed.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{actions.map((action) => (
<div
key={action.id}
onClick={() => handleActionClick(action.id)}
className={`
cursor-pointer p-4 border-2 rounded-xl transition-all duration-200
${selectedAction === action.id
? `border-${action.color}-600 bg-${action.color}-50 shadow-sm`
: 'border-gray-100 hover:border-gray-200 hover:bg-gray-50'}
`}
>
<div className="flex items-center gap-3 mb-2">
<div className={`p-2 rounded-lg bg-white border border-gray-100`}>
{action.icon}
</div>
<h4 className="font-bold text-sm text-gray-900">{action.label}</h4>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
{action.description}
</p>
</div>
))}
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-gray-500" />
Comments / Reason
</h3>
<Textarea
placeholder="Provide a detailed reason for your decision..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[120px] text-sm resize-none"
/>
</div>
{selectedAction === 'CANCEL' && (
<div className="p-3 bg-red-50 border border-red-100 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="text-xs text-red-800">
<p className="font-bold mb-1">Warning: Irreversible Action</p>
<p>Cancelling this request will permanently close it. This action cannot be undone.</p>
</div>
</div>
)}
</div>
<DialogFooter className="border-t pt-4">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedAction || !comments.trim() || submitting}
className={`
min-w-[120px]
${selectedAction === 'CANCEL' ? 'bg-red-600 hover:bg-red-700' : 'bg-purple-600 hover:bg-purple-700'}
`}
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
'Confirm Action'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,782 @@
/**
* InitiatorProposalApprovalModal Component
* Modal for Step 2: Requestor Evaluation & Confirmation
* Allows initiator to review dealer's proposal and approve/reject
*/
import { useState, useMemo, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
CheckCircle,
XCircle,
FileText,
IndianRupee,
Calendar,
MessageSquare,
Download,
Eye,
Plus,
Minus,
} from 'lucide-react';
import { toast } from 'sonner';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
import '@/components/common/FilePreview/FilePreview.css';
import './DealerProposalModal.css';
interface CostItem {
id: string;
description: string;
amount: number;
}
interface ProposalData {
proposalDocument?: {
name: string;
url?: string;
id?: string;
};
costBreakup: CostItem[];
expectedCompletionDate: string;
otherDocuments?: Array<{
name: string;
url?: string;
id?: string;
}>;
dealerComments: string;
submittedAt?: string;
}
interface InitiatorProposalApprovalModalProps {
isOpen: boolean;
onClose: () => void;
onApprove: (comments: string) => Promise<void>;
onReject: (comments: string) => Promise<void>;
onRequestRevision?: (comments: string) => Promise<void>;
proposalData: ProposalData | null;
dealerName?: string;
activityName?: string;
requestId?: string;
request?: any; // Request object to check IO blocking status
previousProposalData?: any;
}
export function InitiatorProposalApprovalModal({
isOpen,
onClose,
onApprove,
onReject,
onRequestRevision,
proposalData,
dealerName = 'Dealer',
activityName = 'Activity',
requestId: _requestId, // Prefix with _ to indicate intentionally unused
request,
previousProposalData,
}: InitiatorProposalApprovalModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
const [showPreviousProposal, setShowPreviousProposal] = useState(false);
// Check if IO is blocked (IO blocking moved to Requestor Evaluation level)
const internalOrder = request?.internalOrder || request?.internal_order;
const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const isIOBlocked = ioBlockedAmount > 0;
const [previewDoc, setPreviewDoc] = useState<{
name: string;
url: string;
type?: string;
size?: number;
id?: string;
} | null>(null);
// Calculate total budget
const totalBudget = useMemo(() => {
if (!proposalData?.costBreakup) return 0;
// Ensure costBreakup is an array
const costBreakup = Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []);
if (!Array.isArray(costBreakup)) return 0;
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
return sum + (Number(amount) || 0);
}, 0);
}, [proposalData]);
// Format date
const formatDate = (dateString: string) => {
if (!dateString) return '—';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-IN', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
} catch {
return dateString;
}
};
// Check if document can be previewed
const canPreviewDocument = (doc: { name: string; url?: string; id?: string }): boolean => {
if (!doc.name) return false;
const name = doc.name.toLowerCase();
return name.endsWith('.pdf') ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg') ||
name.endsWith('.png') ||
name.endsWith('.gif') ||
name.endsWith('.webp');
};
// Handle document preview - leverage FilePreview's internal fetching
const handlePreviewDocument = (doc: { name: string; url?: string; id?: string; storageUrl?: string; documentId?: string }) => {
let fileUrl = doc.url || doc.storageUrl || '';
const documentId = doc.id || doc.documentId || '';
if (!documentId && !fileUrl) {
toast.error('Document preview not available');
return;
}
// Handle relative URLs for snapshots
if (fileUrl && !fileUrl.startsWith('http') && !fileUrl.startsWith('blob:')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
setPreviewDoc({
name: doc.name || 'Document',
url: fileUrl || (documentId ? getDocumentPreviewUrl(documentId) : ''),
type: (doc.name || '').toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg',
id: documentId
});
};
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
if (previewDoc?.url && previewDoc.url.startsWith('blob:')) {
window.URL.revokeObjectURL(previewDoc.url);
}
};
}, [previewDoc]);
const handleApprove = async () => {
if (!comments.trim()) {
toast.error('Please provide approval comments');
return;
}
try {
setSubmitting(true);
setActionType('approve');
await onApprove(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to approve proposal:', error);
toast.error('Failed to approve proposal. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleReject = async () => {
if (!comments.trim()) {
toast.error('Please provide rejection reason');
return;
}
try {
setSubmitting(true);
setActionType('reject');
await onReject(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to reject proposal:', error);
toast.error('Failed to reject proposal. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleRequestRevision = async () => {
if (!comments.trim()) {
toast.error('Please provide reasons for requesting a revision');
return;
}
if (!onRequestRevision) {
toast.error('Revision feature is not available');
return;
}
try {
setSubmitting(true);
setActionType('revision');
await onRequestRevision(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to request revision:', error);
toast.error('Failed to request revision. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleReset = () => {
setComments('');
setActionType(null);
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
// Don't return null - show modal even if proposalData is not loaded yet
// This allows the modal to open and show a loading/empty state
if (!isOpen) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl">
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
Requestor Evaluation & Confirmation
</DialogTitle>
<DialogDescription className="text-xs lg:text-sm">
Step 2: Review dealer proposal and make a decision
</DialogDescription>
<div className="space-y-1 mt-2 text-xs text-gray-600">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
</div>
<div className="mt-1 text-amber-600 font-medium">
Decision: <strong>Confirms?</strong> (YES Continue to Dept Lead / NO Request is cancelled)
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6">
{/* Previous Proposal Reference Section */}
{previousProposalData && (
<div className="mb-6">
<div
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
onClick={() => setShowPreviousProposal(!showPreviousProposal)}
>
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-amber-700" />
<span className="text-sm font-semibold text-amber-900">Reference: Previous Proposal Details (last revision)</span>
<Badge variant="secondary" className="bg-amber-200 text-amber-800 text-[10px]">
{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
</Badge>
</div>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-amber-700">
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
</Button>
</div>
{showPreviousProposal && (
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
{/* Header Info: Date & Document */}
<div className="flex flex-wrap gap-4 text-xs mt-3">
{previousProposalData.expectedCompletionDate && (
<div className="flex items-center gap-1.5 text-gray-700">
<Calendar className="w-3.5 h-3.5 text-gray-500" />
<span className="font-medium">Expected Completion:</span>
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
</div>
)}
{previousProposalData.documentUrl && (
<div className="flex items-center gap-1.5">
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
<>
<Eye className="w-3.5 h-3.5 text-blue-500" />
<a
href={previousProposalData.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
>
View Previous Document
</a>
</>
) : (
<>
<Download className="w-3.5 h-3.5 text-blue-500" />
<a
href={previousProposalData.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
>
Download Previous Document
</a>
</>
)}
</div>
)}
</div>
{/* Cost Breakdown */}
{(previousProposalData.costItems || previousProposalData.costBreakup) && (previousProposalData.costItems || previousProposalData.costBreakup).length > 0 && (
<div className="w-full pt-2 border-t border-amber-200/50">
<p className="text-[10px] font-semibold text-gray-700 mb-2 flex items-center gap-1">
<IndianRupee className="w-3 h-3" />
Previous Cost Breakdown
</p>
<div className="border rounded-md overflow-hidden text-[10px]">
<table className="w-full text-left">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="p-2 font-medium">Description</th>
<th className="p-2 font-medium text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y">
{(previousProposalData.costItems || previousProposalData.costBreakup).map((item: any, idx: number) => (
<tr key={idx} className="bg-white">
<td className="p-2 text-gray-800">{item.description}</td>
<td className="p-2 text-right text-gray-800 font-medium">
{Number(item.amount).toLocaleString('en-IN')}
</td>
</tr>
))}
<tr className="bg-gray-50 font-bold border-t">
<td className="p-2 text-gray-900">Total</td>
<td className="p-2 text-right text-gray-900">
{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* Additional/Supporting Documents */}
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
<div className="w-full pt-2 border-t border-amber-200/50">
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
<FileText className="w-3 h-3" />
Supporting Documents
</p>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard
key={idx}
document={{
documentId: doc.documentId || doc.id || '',
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
uploadedAt: doc.uploadedAt || new Date().toISOString()
}}
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
onDownload={async (id) => {
if (id) {
await downloadDocument(id);
} else {
let downloadUrl = doc.storageUrl || doc.documentUrl;
if (downloadUrl && !downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
if (downloadUrl) window.open(downloadUrl, '_blank');
}
}}
/>
))}
</div>
</div>
)}
{/* Comments */}
{(previousProposalData.comments || previousProposalData.dealerComments) && (
<div className="w-full pt-2 border-t border-amber-200/50">
<p className="text-[10px] font-semibold text-gray-700 mb-1 flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
Previous Comments
</p>
<div className="text-[10px] text-gray-600 bg-white p-2 border border-gray-100 rounded italic">
"{previousProposalData.comments || previousProposalData.dealerComments}"
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
{/* Left Column - Documents */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Proposal Document Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-600" />
Proposal Document
</h3>
</div>
{proposalData?.proposalDocument ? (
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
{proposalData.proposalDocument.name}
</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500 truncate">
Submitted on {formatDate(proposalData.submittedAt)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{proposalData.proposalDocument.id && (
<>
{canPreviewDocument(proposalData.proposalDocument) && (
<button
type="button"
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (proposalData.proposalDocument?.id) {
await downloadDocument(proposalData.proposalDocument.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</>
)}
</div>
</div>
) : (
<p className="text-xs text-gray-500 italic">No proposal document available</p>
)}
</div>
{/* Other Supporting Documents */}
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-600" />
Other Supporting Documents
</h3>
<Badge variant="secondary" className="text-xs">
{proposalData.otherDocuments.length} file(s)
</Badge>
</div>
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
{proposalData.otherDocuments.map((doc, index) => (
<div
key={index}
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
>
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
{doc.name}
</p>
</div>
{doc.id && (
<div className="flex items-center gap-1 flex-shrink-0">
{canPreviewDocument(doc) && (
<button
type="button"
onClick={() => handlePreviewDocument(doc)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (doc.id) {
await downloadDocument(doc.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Right Column - Planning & Details */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Cost Breakup Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-green-600" />
Cost Breakup
</h3>
</div>
{(() => {
// Ensure costBreakup is an array
const costBreakup = proposalData?.costBreakup
? (Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []))
: [];
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
<>
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
<div className="grid grid-cols-2 gap-4 text-xs lg:text-sm font-semibold text-gray-700">
<div>Item Description</div>
<div className="text-right">Amount</div>
</div>
</div>
<div className="divide-y">
{costBreakup.map((item: any, index: number) => (
<div key={item?.id || item?.description || index} className="px-3 lg:px-4 py-2 lg:py-3 grid grid-cols-2 gap-4">
<div className="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div>
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
))}
</div>
</div>
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-[--re-green]" />
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
</div>
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</>
) : (
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
);
})()}
</div>
{/* Timeline Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<Calendar className="w-4 h-4 text-purple-600" />
Expected Completion Date
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
<p className="text-sm lg:text-base font-semibold text-gray-900">
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
</p>
</div>
</div>
</div>
{/* Comments Section - Side by Side */}
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
{/* Dealer Comments */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-blue-600" />
Dealer Comments
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
<p className="text-xs text-gray-700 whitespace-pre-wrap">
{proposalData?.dealerComments || 'No comments provided'}
</p>
</div>
</div>
{/* Your Decision & Comments */}
<div className="space-y-2">
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
<Textarea
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
/>
<p className="text-xs text-gray-500">{comments.length} characters</p>
</div>
</div>
</div>
{/* Warning for missing comments */}
{!comments.trim() && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2">
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p>
</div>
)}
</div>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-3 lg:pt-4 flex-shrink-0 border-t bg-gray-50">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="border-2 w-full sm:w-auto"
>
Cancel
</Button>
<div className="flex flex-col gap-2 w-full sm:w-auto">
<div className="flex flex-col sm:flex-row gap-2">
<Button
onClick={handleRequestRevision}
disabled={!comments.trim() || submitting}
variant="secondary"
className="bg-amber-100 hover:bg-amber-200 text-amber-900 border border-amber-200 w-full sm:w-auto"
>
{submitting && actionType === 'revision' ? (
'Requesting...'
) : (
<>
<MessageSquare className="w-4 h-4 mr-2" />
Request Revised Quotation
</>
)}
</Button>
<Button
onClick={handleReject}
disabled={!comments.trim() || submitting}
variant="destructive"
className="bg-red-600 hover:bg-red-700 w-full sm:w-auto"
>
{submitting && actionType === 'reject' ? (
'Rejecting...'
) : (
<>
<XCircle className="w-4 h-4 mr-2" />
Reject (Cancel Request)
</>
)}
</Button>
<Button
onClick={handleApprove}
disabled={!comments.trim() || !isIOBlocked || submitting}
className="bg-green-600 hover:bg-green-700 text-white disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
title={!isIOBlocked ? 'Please block IO budget before approving' : ''}
>
{submitting && actionType === 'approve' ? (
'Approving...'
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Approve (Continue to Dept Lead)
</>
)}
</Button>
</div>
{/* Warning for IO not blocked - shown below Approve button */}
{!isIOBlocked && (
<p className="text-xs text-red-600 text-center sm:text-left">
Please block IO budget in the IO Tab before approving
</p>
)}
</div>
</DialogFooter>
</DialogContent>
{/* Standardized File Preview */}
{previewDoc && (
<FilePreview
fileName={previewDoc.name}
fileType={previewDoc.type || ''}
fileUrl={previewDoc.url}
fileSize={previewDoc.size}
attachmentId={previewDoc.id}
onDownload={downloadDocument}
open={!!previewDoc}
onClose={() => setPreviewDoc(null)}
/>
)}
</Dialog>
);
}

View File

@ -0,0 +1,307 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
FileText,
Calendar,
Receipt,
AlignLeft
} from "lucide-react";
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
interface SnapshotDetailsModalProps {
isOpen: boolean;
onClose: () => void;
snapshot: any;
type: 'PROPOSAL' | 'COMPLETION';
title?: string;
}
export function SnapshotDetailsModal({
isOpen,
onClose,
snapshot,
type,
title
}: SnapshotDetailsModalProps) {
// State for preview
const [previewDoc, setPreviewDoc] = useState<{
fileName: string;
fileType: string;
documentId: string;
fileUrl?: string;
fileSize?: number;
} | null>(null);
if (!snapshot) return null;
const isProposal = type === 'PROPOSAL';
// Helper to format currency
const formatCurrency = (amount: number | string) => {
return Number(amount || 0).toLocaleString('en-IN', {
maximumFractionDigits: 2,
style: 'currency',
currency: 'INR'
});
};
// Helper to format date
const formatDate = (dateString: string) => {
if (!dateString) return null;
try {
return new Date(dateString).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
} catch {
return dateString;
}
};
// Helper to check if file is previewable
const canPreview = (fileName: string): boolean => {
if (!fileName) return false;
const name = fileName.toLowerCase();
return name.endsWith('.pdf') ||
!!name.match(/\.(jpg|jpeg|png|gif|webp)$/i);
};
// Helper to get file type for DocumentCard
const getFileType = (fileName: string) => {
const ext = (fileName || '').split('.').pop()?.toLowerCase();
if (ext === 'pdf') return 'pdf';
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) return 'image';
return 'file';
};
// Handle document preview click
const handlePreview = (doc: any) => {
const fileName = doc.fileName || doc.originalFileName || (isProposal ? 'Proposal Document' : 'Completion Document');
const documentId = doc.documentId || '';
const fileType = fileName.toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';
let fileUrl = '';
if (documentId) {
fileUrl = getDocumentPreviewUrl(documentId);
} else {
// Fallback for documents without ID (using direct storageUrl)
fileUrl = doc.storageUrl || doc.documentUrl || '';
if (fileUrl && !fileUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
}
setPreviewDoc({
fileName,
fileType,
documentId,
fileUrl,
fileSize: doc.sizeBytes
});
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle className="flex items-center gap-2">
{isProposal ? (
<FileText className="w-5 h-5 text-blue-600" />
) : (
<Receipt className="w-5 h-5 text-green-600" />
)}
{title || (isProposal ? 'Proposal Snapshot Details' : 'Completion Snapshot Details')}
</DialogTitle>
<DialogDescription>
View detailed snapshot of the {isProposal ? 'proposal' : 'completion request'} at this version.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 px-6 py-4">
<div className="space-y-6">
{/* Header Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-xs text-gray-500 font-medium mb-1">
{isProposal ? 'Total Budget' : 'Total Expenses'}
</p>
<p className={`text-lg font-bold ${isProposal ? 'text-blue-700' : 'text-green-700'}`}>
{formatCurrency(snapshot.totalBudget || snapshot.totalExpenses)}
</p>
</div>
{isProposal && snapshot.expectedCompletionDate && (
<div className="p-3 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-xs text-gray-500 font-medium mb-1 flex items-center gap-1">
<Calendar className="w-3 h-3" />
Expected Completion
</p>
<p className="text-sm font-semibold text-gray-700">
{formatDate(snapshot.expectedCompletionDate)}
</p>
</div>
)}
</div>
{/* Main Document */}
{snapshot.documentUrl && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1">
Primary Document
</h4>
<DocumentCard
document={{
documentId: '',
name: isProposal ? 'Proposal Document' : 'Completion Document',
fileType: getFileType(snapshot.documentUrl),
uploadedAt: new Date().toISOString()
}}
onPreview={canPreview(snapshot.documentUrl) ? () => handlePreview({
fileName: isProposal ? 'Proposal Document' : 'Completion Document',
documentUrl: snapshot.documentUrl
}) : undefined}
onDownload={async () => {
// Handle download for document without ID
let downloadUrl = snapshot.documentUrl;
if (!downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
window.open(downloadUrl, '_blank');
}}
/>
</div>
)}
{/* Supporting Documents */}
{snapshot.otherDocuments && snapshot.otherDocuments.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1 flex items-center justify-between">
<span>Supporting Documents</span>
<Badge variant="secondary" className="text-[10px] h-5">
{snapshot.otherDocuments.length} Files
</Badge>
</h4>
<div className="space-y-2">
{snapshot.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard
key={idx}
document={{
documentId: doc.documentId || '',
name: doc.originalFileName || doc.fileName || 'Supporting Document',
fileType: getFileType(doc.originalFileName || doc.fileName || ''),
uploadedAt: doc.uploadedAt || new Date().toISOString()
}}
onPreview={canPreview(doc.originalFileName || doc.fileName || '') ? () => handlePreview(doc) : undefined}
onDownload={doc.documentId ? downloadDocument : async () => {
let downloadUrl = doc.storageUrl || doc.documentUrl;
if (downloadUrl && !downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
if (downloadUrl) window.open(downloadUrl, '_blank');
}}
/>
))}
</div>
</div>
)}
{/* Cost Breakup / Expenses */}
{(snapshot.costItems || snapshot.expenses) && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1">
{isProposal ? 'Cost Breakdown' : 'Expenses Breakdown'}
</h4>
<div className="border rounded-md overflow-hidden text-sm">
<table className="w-full text-left">
<thead className="bg-gray-50 text-gray-600 text-xs uppercase">
<tr>
<th className="p-3 font-medium">Description</th>
<th className="p-3 font-medium text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{(snapshot.costItems || snapshot.expenses).length > 0 ? (
(snapshot.costItems || snapshot.expenses).map((item: any, idx: number) => (
<tr key={idx} className="bg-white hover:bg-gray-50/50">
<td className="p-3 text-gray-800">{item.description}</td>
<td className="p-3 text-right text-gray-900 font-medium tabular-nums">
{formatCurrency(item.amount)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={2} className="p-4 text-center text-gray-500 italic text-xs">
No breakdown items available
</td>
</tr>
)}
<tr className="bg-gray-50/80 font-semibold text-gray-900 border-t-2 border-gray-100">
<td className="p-3">Total</td>
<td className="p-3 text-right tabular-nums">
{formatCurrency(snapshot.totalBudget || snapshot.totalExpenses)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* Comments */}
{snapshot.comments && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1 flex items-center gap-1">
<AlignLeft className="w-4 h-4" />
Comments
</h4>
<div className="bg-gray-50 rounded-lg p-3 text-sm text-gray-700 italic border border-gray-100">
{snapshot.comments}
</div>
</div>
)}
</div>
</div>
<div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
<Button onClick={onClose}>Close</Button>
</div>
</DialogContent>
</Dialog>
{/* File Preview */}
{previewDoc && (
<FilePreview
fileName={previewDoc.fileName}
fileType={previewDoc.fileType}
fileUrl={previewDoc.fileUrl}
fileSize={previewDoc.fileSize}
attachmentId={previewDoc.documentId}
onDownload={downloadDocument}
open={!!previewDoc}
onClose={() => setPreviewDoc(null)}
/>
)}
</>
);
}

View File

@ -0,0 +1,18 @@
/**
* Dealer Claim Request Detail Modals
*
* These modals are specific to Dealer Claim request details.
* Located in: src/dealer-claim/components/request-detail/modals/
*/
export { AdditionalApproverReviewModal } from './AdditionalApproverReviewModal';
export { CreditNoteSAPModal } from './CreditNoteSAPModal';
export { DealerCompletionDocumentsModal } from './DealerCompletionDocumentsModal';
export { DealerProposalSubmissionModal } from './DealerProposalSubmissionModal';
export { DeptLeadIOApprovalModal } from './DeptLeadIOApprovalModal';
export { DMSPushModal } from './DMSPushModal';
export { EditClaimAmountModal } from './EditClaimAmountModal';
export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal';
export { InitiatorActionModal } from './InitiatorActionModal';
export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';
export { SnapshotDetailsModal } from './SnapshotDetailsModal';

42
src/dealer-claim/index.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* Dealer Claim Request Flow
*
* This module exports all components, hooks, utilities, and types
* specific to Dealer Claim requests. This allows for complete segregation
* of dealer claim functionality.
*
* LOCATION: src/dealer-claim/
*
* To remove Dealer Claim flow completely:
* 1. Delete this entire folder: src/dealer-claim/
* 2. Remove from src/flows.ts registry
* 3. Done! All dealer claim code is removed.
*/
// Request Detail Components
export { DealerClaimOverviewTab } from './components/request-detail/OverviewTab';
export { DealerClaimWorkflowTab } from './components/request-detail/WorkflowTab';
export { IOTab } from './components/request-detail/IOTab';
// Request Detail Cards
export * from './components/request-detail/claim-cards';
// Request Detail Modals
export * from './components/request-detail/modals';
// Request Creation Components
export { ClaimManagementWizard } from './components/request-creation/ClaimManagementWizard';
// Request Detail Screen (Complete standalone screen)
export { DealerClaimRequestDetail } from './pages/RequestDetail';
// Dashboard
export { DealerDashboard } from './pages/Dashboard';
// Filters
export { DealerRequestsFilters } from './components/DealerRequestsFilters';
export { DealerClosedRequestsFilters } from './components/DealerClosedRequestsFilters';
export { DealerUserAllRequestsFilters } from './components/DealerUserAllRequestsFilters';
// Re-export types
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';

View File

@ -0,0 +1,671 @@
import { useEffect, useState, useMemo } from 'react';
import { Shield, Clock, FileText, ChartColumn, ChartPie, Activity, Target, DollarSign, Zap, Package, TrendingUp, TrendingDown, CircleCheckBig, CircleX, CreditCard, TriangleAlert } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { getDealerDashboard, type DashboardKPIs as DashboardKPIsType, type CategoryData as CategoryDataType } from '@/services/dealerClaimApi';
import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
// Use types from dealerClaimApi
type DashboardKPIs = DashboardKPIsType;
type CategoryData = CategoryDataType;
interface DashboardProps {
onNavigate?: (page: string) => void;
onNewRequest?: () => void;
}
export function DealerDashboard({ onNavigate, onNewRequest: _onNewRequest }: DashboardProps) {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [kpis, setKpis] = useState<DashboardKPIs>({
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
const [categoryData, setCategoryData] = useState<CategoryData[]>([]);
const [dateRange, _setDateRange] = useState<string>('all');
const [startDate, _setStartDate] = useState<string | undefined>();
const [endDate, _setEndDate] = useState<string | undefined>();
const fetchDashboardData = async (isRefresh = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
// Fetch dealer claims dashboard data
const data = await getDealerDashboard(
dateRange || 'all',
startDate,
endDate
);
setKpis(data.kpis || {
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
setCategoryData(data.categoryData || []);
} catch (error: any) {
console.error('[DealerDashboard] Error fetching data:', error);
toast.error('Failed to load dashboard data. Please try again later.');
// Reset to empty state on error
setKpis({
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
setCategoryData([]);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchDashboardData();
}, []);
const formatCurrency = (amount: number, showExactRupees = false) => {
// Handle null, undefined, or invalid values
if (amount == null || isNaN(amount)) {
return '₹0';
}
// Convert to number if it's a string
const numAmount = typeof amount === 'string' ? parseFloat(amount) : Number(amount);
// Handle zero or negative values
if (numAmount <= 0) {
return '₹0';
}
// If showExactRupees is true or amount is less than 10,000, show exact rupees
if (showExactRupees || numAmount < 10000) {
return `${Math.round(numAmount).toLocaleString('en-IN')}`;
}
if (numAmount >= 100000) {
return `${(numAmount / 100000).toFixed(1)}L`;
}
if (numAmount >= 1000) {
return `${(numAmount / 1000).toFixed(1)}K`;
}
// Show exact rupee amount for amounts less than 1000 (e.g., ₹100, ₹200, ₹999)
return `${Math.round(numAmount).toLocaleString('en-IN')}`;
};
const formatNumber = (num: number) => {
return num.toLocaleString('en-IN');
};
const calculateApprovalRate = () => {
if (kpis.totalClaims === 0) return 0;
return ((kpis.approved / kpis.totalClaims) * 100).toFixed(1);
};
const calculateCreditRate = () => {
if (kpis.approved === 0) return 0;
return ((kpis.credited / kpis.approved) * 100).toFixed(1);
};
// Prepare data for pie chart (Distribution by Activity Type)
const distributionData = useMemo(() => {
const totalRaised = categoryData.reduce((sum, cat) => sum + cat.raised, 0);
if (totalRaised === 0) return [];
return categoryData.map(cat => ({
name: cat.activityType.length > 20 ? cat.activityType.substring(0, 20) + '...' : cat.activityType,
value: cat.raised,
fullName: cat.activityType,
percentage: ((cat.raised / totalRaised) * 100).toFixed(0),
}));
}, [categoryData]);
// Prepare data for bar chart (Status by Category)
const statusByCategoryData = useMemo(() => {
return categoryData.map(cat => ({
name: cat.activityType.length > 15 ? cat.activityType.substring(0, 15) + '...' : cat.activityType,
fullName: cat.activityType,
Raised: cat.raised,
Approved: cat.approved,
Rejected: cat.rejected,
Pending: cat.pending,
}));
}, [categoryData]);
// Prepare data for value comparison chart (keep original values, formatCurrency will handle display)
const valueComparisonData = useMemo(() => {
return categoryData.map(cat => ({
name: cat.activityType.length > 15 ? cat.activityType.substring(0, 15) + '...' : cat.activityType,
fullName: cat.activityType,
Raised: cat.raisedValue, // Keep original value
Approved: cat.approvedValue, // Keep original value
Credited: cat.creditedValue, // Keep original value
}));
}, [categoryData]);
const COLORS = ['#166534', '#15803d', '#16a34a', '#22c55e', '#4ade80', '#86efac', '#bbf7d0'];
// Find best performing category
const bestPerforming = useMemo(() => {
if (categoryData.length === 0) return null;
return categoryData.reduce((best, cat) =>
cat.approvalRate > (best?.approvalRate || 0) ? cat : best
);
}, [categoryData]);
// Find highest value category
const highestValue = useMemo(() => {
if (categoryData.length === 0) return null;
return categoryData.reduce((best, cat) =>
cat.raisedValue > (best?.raisedValue || 0) ? cat : best
);
}, [categoryData]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-blue-600" />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
);
}
// Show empty state if no data
const hasNoData = kpis.totalClaims === 0 && categoryData.length === 0;
if (hasNoData) {
return (
<div className="space-y-6 max-w-[1600px] mx-auto p-4">
{/* Hero Section */}
<Card className="border-0 shadow-xl relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" />
<CardContent className="relative z-10 p-8 lg:p-12">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<div className="text-white">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
<Shield className="w-8 h-8 text-slate-900" />
</div>
<div>
<h1 className="text-4xl text-white font-bold">Claims Analytics Dashboard</h1>
<p className="text-xl text-gray-200 mt-1">Comprehensive insights into approval workflows</p>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-8">
<Button
onClick={() => onNavigate?.('/new-request')}
className="bg-blue-600 hover:bg-blue-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<FileText className="w-5 h-5 mr-2" />
Create New Claim
</Button>
<Button
onClick={() => {
setRefreshing(true);
fetchDashboardData(true);
}}
disabled={refreshing}
variant="outline"
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
>
<RefreshCw className={`w-5 h-5 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Empty State */}
<Card className="shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
<ChartPie className="w-12 h-12 text-gray-400" />
</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">No Claims Data Available</h2>
<p className="text-gray-600 text-center max-w-md mb-6">
You don't have any claims data yet. Once you create and submit claim requests, your analytics will appear here.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button
onClick={() => onNavigate?.('/new-request')}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<FileText className="w-5 h-5 mr-2" />
Create Your First Claim
</Button>
<Button
onClick={() => {
setRefreshing(true);
fetchDashboardData(true);
}}
disabled={refreshing}
variant="outline"
>
<RefreshCw className={`w-5 h-5 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh Data
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6 max-w-[1600px] mx-auto p-4">
{/* Hero Section */}
<Card className="border-0 shadow-xl relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" />
<CardContent className="relative z-10 p-8 lg:p-12">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<div className="text-white">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
<Shield className="w-8 h-8 text-slate-900" />
</div>
<div>
<h1 className="text-4xl text-white font-bold">Claims Analytics Dashboard</h1>
<p className="text-xl text-gray-200 mt-1">Comprehensive insights into approval workflows</p>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-8">
<Button
onClick={() => onNavigate?.('/requests?status=pending')}
className="bg-blue-600 hover:bg-blue-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<Clock className="w-5 h-5 mr-2" />
View Pending Claims
</Button>
<Button
onClick={() => onNavigate?.('/requests')}
className="bg-emerald-600 hover:bg-emerald-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<FileText className="w-5 h-5 mr-2" />
My Claims
</Button>
</div>
</div>
<div className="hidden lg:flex items-center gap-4">
<div className="w-24 h-24 bg-yellow-400/20 rounded-full flex items-center justify-center">
<div className="w-16 h-16 bg-yellow-400/30 rounded-full flex items-center justify-center">
<ChartColumn className="w-8 h-8 text-yellow-400" />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<Card className="border-l-4 border-l-blue-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Raised Claims</CardTitle>
<div className="p-2 rounded-lg bg-blue-50">
<FileText className="h-4 w-4 text-blue-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.totalClaims)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.totalValue, true)}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Approved</CardTitle>
<div className="p-2 rounded-lg bg-green-50">
<CircleCheckBig className="h-4 w-4 text-green-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.approved)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3 text-green-600" />
<p className="text-xs text-green-600">{calculateApprovalRate()}% approval rate</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-red-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Rejected</CardTitle>
<div className="p-2 rounded-lg bg-red-50">
<CircleX className="h-4 w-4 text-red-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.rejected)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingDown className="h-3 w-3 text-red-600" />
<p className="text-xs text-red-600">
{kpis.totalClaims > 0 ? ((kpis.rejected / kpis.totalClaims) * 100).toFixed(1) : 0}% rejection rate
</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Pending</CardTitle>
<div className="p-2 rounded-lg bg-orange-50">
<Clock className="h-4 w-4 text-orange-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.pending)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.pendingValue)}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-emerald-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Credited</CardTitle>
<div className="p-2 rounded-lg bg-emerald-50">
<CreditCard className="h-4 w-4 text-emerald-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.credited)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3 text-emerald-600" />
<p className="text-xs text-emerald-600">{calculateCreditRate()}% credit rate</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Pending Credit</CardTitle>
<div className="p-2 rounded-lg bg-amber-50">
<TriangleAlert className="h-4 w-4 text-amber-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.pendingCredit)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.pendingCreditValue)}</p>
</CardContent>
</Card>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Distribution by Activity Type */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-purple-100 rounded-lg">
<ChartPie className="h-5 w-5 text-purple-600" />
</div>
<div>
<CardTitle>Claims Distribution by Activity Type</CardTitle>
<CardDescription>Total claims raised across activity types</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={distributionData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percentage }) => `${name}: ${percentage}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{distributionData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="grid grid-cols-3 gap-2 mt-4">
{distributionData.slice(0, 3).map((item, index) => (
<div key={index} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
<div className="w-3 h-3 rounded" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
<div>
<p className="text-xs text-gray-600">{item.name}</p>
<p className="text-sm text-gray-900">{formatNumber(item.value)}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Status by Category */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-blue-100 rounded-lg">
<ChartColumn className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle>Claims Status by Activity Type</CardTitle>
<CardDescription>Count comparison across workflow stages</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={statusByCategoryData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="Raised" fill="#3b82f6" />
<Bar dataKey="Approved" fill="#22c55e" />
<Bar dataKey="Rejected" fill="#ef4444" />
<Bar dataKey="Pending" fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Detailed Category Breakdown */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-emerald-100 rounded-lg">
<Activity className="h-5 w-5 text-emerald-600" />
</div>
<div>
<CardTitle>Detailed Activity Type Breakdown</CardTitle>
<CardDescription>In-depth analysis of claims by type and status</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h3 className="text-lg mb-4 text-gray-900">Activity Type Value Comparison</h3>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={valueComparisonData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis tickFormatter={(value) => formatCurrency(value)} />
<Tooltip
formatter={(value: number) => formatCurrency(value)}
labelFormatter={(label) => label}
/>
<Legend />
<Bar dataKey="Raised" fill="#3b82f6" />
<Bar dataKey="Approved" fill="#22c55e" />
<Bar dataKey="Credited" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
{categoryData.slice(0, 3).map((cat, index) => (
<Card key={index} className="shadow-md hover:shadow-lg transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{cat.activityType}</CardTitle>
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">
{cat.approvalRate.toFixed(1)}% approved
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Raised:</span>
<span className="text-gray-900">{formatNumber(cat.raised)} ({formatCurrency(cat.raisedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Approved:</span>
<span className="text-green-600">{formatNumber(cat.approved)} ({formatCurrency(cat.approvedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Rejected:</span>
<span className="text-red-600">{formatNumber(cat.rejected)} ({formatCurrency(cat.rejectedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Pending:</span>
<span className="text-orange-600">{formatNumber(cat.pending)} ({formatCurrency(cat.pendingValue)})</span>
</div>
<div className="h-px bg-gray-200 my-2" />
<div className="flex justify-between text-sm">
<span className="text-gray-600">Credited:</span>
<span className="text-emerald-600">{formatNumber(cat.credited)} ({formatCurrency(cat.creditedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Pending Credit:</span>
<span className="text-amber-600">{formatNumber(cat.pendingCredit)} ({formatCurrency(cat.pendingCreditValue)})</span>
</div>
</div>
<div className="pt-2">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span>Credit Rate</span>
<span>{cat.creditRate.toFixed(1)}%</span>
</div>
<Progress value={cat.creditRate} className="h-2" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
</CardContent>
</Card>
{/* Performance Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-t-4 border-t-green-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<Target className="h-6 w-6 text-green-600" />
</div>
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Best Performing</h3>
<p className="text-xl text-gray-900 mb-1">{bestPerforming?.activityType || 'N/A'}</p>
<p className="text-sm text-green-600">{bestPerforming?.approvalRate.toFixed(2) || 0}% approval rate</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-blue-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-blue-100 rounded-lg">
<DollarSign className="h-6 w-6 text-blue-600" />
</div>
<Activity className="h-5 w-5 text-blue-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Top Activity Type</h3>
<p className="text-xl text-gray-900 mb-1">{highestValue?.activityType || 'N/A'}</p>
<p className="text-sm text-blue-600">{highestValue ? formatCurrency(highestValue.raisedValue, true) : '₹0'} raised</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-emerald-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-emerald-100 rounded-lg">
<Zap className="h-6 w-6 text-emerald-600" />
</div>
<CircleCheckBig className="h-5 w-5 text-emerald-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Overall Credit Rate</h3>
<p className="text-xl text-gray-900 mb-1">{calculateCreditRate()}%</p>
<p className="text-sm text-emerald-600">{formatNumber(kpis.credited)} claims credited</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-amber-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-amber-100 rounded-lg">
<Package className="h-6 w-6 text-amber-600" />
</div>
<TriangleAlert className="h-5 w-5 text-amber-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Pending Action</h3>
<p className="text-xl text-gray-900 mb-1">{formatNumber(kpis.pendingCredit)}</p>
<p className="text-sm text-amber-600">{formatCurrency(kpis.pendingCreditValue)} awaiting credit</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,801 @@
/**
* Dealer Claim Request Detail Screen
*
* Standalone, dedicated request detail screen for Dealer Claim requests.
* This is a complete module that uses dealer claim specific components.
*
* LOCATION: src/dealer-claim/pages/RequestDetail.tsx
*
* IMPORTANT: This entire file and all its dependencies are in src/dealer-claim/ folder.
* Deleting src/dealer-claim/ folder removes ALL dealer claim related code.
*/
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ClipboardList,
TrendingUp,
FileText,
Activity,
MessageSquare,
AlertTriangle,
FileCheck,
ShieldX,
RefreshCw,
ArrowLeft,
DollarSign,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
// Context and hooks
import { useAuth } from '@/contexts/AuthContext';
import { useRequestDetails } from '@/hooks/useRequestDetails';
import { useRequestSocket } from '@/hooks/useRequestSocket';
import { useDocumentUpload } from '@/hooks/useDocumentUpload';
import { useModalManager } from '@/hooks/useModalManager';
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
// Dealer Claim Components (import from index to get properly aliased exports)
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
// Shared Components
import { SharedComponents } from '@/shared/components';
const { DocumentsTab, ActivityTab, WorkNotesTab, SummaryTab, RequestDetailHeader, QuickActionsSidebar, RequestDetailModals } = SharedComponents;
// Other components
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi';
import { toast } from 'sonner';
import { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
import { PauseModal } from '@/components/workflow/PauseModal';
import { ResumeModal } from '@/components/workflow/ResumeModal';
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
/**
* Error Boundary Component
*/
class RequestDetailErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: Error | null }> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Dealer Claim RequestDetail Error:', error, errorInfo);
}
override render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold mb-2">Error Loading Request</h2>
<p className="text-gray-600 mb-4">{this.state.error?.message || 'An unexpected error occurred'}</p>
<Button onClick={() => window.location.reload()} className="mr-2">
Reload Page
</Button>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
/**
* Dealer Claim RequestDetailInner Component
*/
function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests = [] }: RequestDetailProps) {
const params = useParams<{ requestId: string }>();
const requestIdentifier = params.requestId || propRequestId || '';
const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'overview';
const [activeTab, setActiveTab] = useState(initialTab);
const [showShareSummaryModal, setShowShareSummaryModal] = useState(false);
const [summaryId, setSummaryId] = useState<string | null>(null);
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
const [loadingSummary, setLoadingSummary] = useState(false);
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false);
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
const { user } = useAuth();
// Custom hooks
const {
request,
apiRequest,
loading: requestLoading,
refreshing,
refreshDetails,
currentApprovalLevel,
isSpectator,
isInitiator,
existingParticipants,
accessDenied,
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
const currentUserId = (user as any)?.userId || '';
// IO tab visibility for dealer claims
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
const showIOTab = isInitiator;
const {
mergedMessages,
unreadWorkNotes,
workNoteAttachments,
setWorkNoteAttachments,
} = useRequestSocket(requestIdentifier, apiRequest, activeTab, user);
const {
uploadingDocument,
triggerFileInput,
previewDocument,
setPreviewDocument,
documentPolicy,
documentError,
setDocumentError,
} = useDocumentUpload(apiRequest, refreshDetails);
// State to temporarily store approval level for modal (used for additional approvers)
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
// Use temporary level if set, otherwise use currentApprovalLevel
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
const {
showApproveModal,
setShowApproveModal,
showRejectModal,
setShowRejectModal,
showAddApproverModal,
setShowAddApproverModal,
showAddSpectatorModal,
setShowAddSpectatorModal,
showSkipApproverModal,
setShowSkipApproverModal,
showActionStatusModal,
setShowActionStatusModal,
skipApproverData,
setSkipApproverData,
actionStatus,
setActionStatus,
handleApproveConfirm: originalHandleApproveConfirm,
handleRejectConfirm: originalHandleRejectConfirm,
handleAddApprover,
handleSkipApprover,
handleAddSpectator,
} = useModalManager(requestIdentifier, effectiveApprovalLevel, refreshDetails);
// Wrapper handlers that clear temporary level after action
const handleApproveConfirm = async (description: string) => {
await originalHandleApproveConfirm(description);
setTemporaryApprovalLevel(null);
};
const handleRejectConfirm = async (description: string) => {
await originalHandleRejectConfirm(description);
setTemporaryApprovalLevel(null);
};
// Closure functionality - only for initiator when request is approved/rejected
// Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
// Closure check completed
const {
conclusionRemark,
setConclusionRemark,
conclusionLoading,
conclusionSubmitting,
aiGenerated,
handleGenerateConclusion,
handleFinalizeConclusion,
generationAttempts,
generationFailed,
maxAttemptsReached,
} = useConclusionRemark(
request,
requestIdentifier,
isInitiator,
refreshDetails,
onBack,
setActionStatus,
setShowActionStatusModal
);
// Load system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
}
};
loadSystemPolicy();
}, []);
// Auto-switch tab when URL query parameter changes
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const tabParam = urlParams.get('tab');
if (tabParam) {
setActiveTab(tabParam);
}
}, [requestIdentifier]);
const handleRefresh = () => {
refreshDetails();
};
// Pause handlers
const handlePause = () => {
setShowPauseModal(true);
};
const handleResume = () => {
setShowResumeModal(true);
};
const handleResumeSuccess = async () => {
await refreshDetails();
};
const handleRetrigger = () => {
setShowRetriggerModal(true);
};
const handlePauseSuccess = async () => {
await refreshDetails();
};
const handleRetriggerSuccess = async () => {
await refreshDetails();
};
const handleShareSummary = async () => {
if (!apiRequest?.requestId) {
toast.error('Request ID not found');
return;
}
if (!summaryId) {
toast.error('Summary not available. Please ensure the request is closed and the summary has been generated.');
return;
}
setShowShareSummaryModal(true);
};
const isClosed = request?.status === 'closed';
// Fetch summary details if request is closed
useEffect(() => {
const fetchSummaryDetails = async () => {
if (!isClosed || !apiRequest?.requestId) {
setSummaryDetails(null);
setSummaryId(null);
return;
}
try {
setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) {
setSummaryId(summary.summaryId);
try {
const details = await getSummaryDetails(summary.summaryId);
setSummaryDetails(details);
} catch (error: any) {
console.error('Failed to fetch summary details:', error);
setSummaryDetails(null);
setSummaryId(null);
}
} else {
setSummaryDetails(null);
setSummaryId(null);
}
} catch (error: any) {
setSummaryDetails(null);
setSummaryId(null);
} finally {
setLoadingSummary(false);
}
};
fetchSummaryDetails();
}, [isClosed, apiRequest?.requestId]);
// Listen for credit note notifications and trigger silent refresh
useEffect(() => {
if (!currentUserId || !apiRequest?.requestId) return;
const socket = getSocket();
if (!socket) return;
joinUserRoom(socket, currentUserId);
const handleNewNotification = (data: { notification: any }) => {
const notif = data?.notification;
if (!notif) return;
const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
refreshDetails();
}
};
socket.on('notification:new', handleNewNotification);
return () => {
socket.off('notification:new', handleNewNotification);
};
}, [currentUserId, apiRequest?.requestId, requestIdentifier, refreshDetails]);
// Get current levels for WorkNotesTab
const currentLevels = (request?.approvalFlow || [])
.filter((flow: any) => flow && typeof flow.step === 'number')
.map((flow: any) => ({
levelNumber: flow.step || 0,
approverName: flow.approver || 'Unknown',
status: flow.status || 'pending',
tatHours: flow.tatHours || 24,
}));
// Loading state
if (requestLoading && !request && !apiRequest) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state">
<div className="text-center">
<RefreshCw className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
<p className="text-gray-600">Loading dealer claim request details...</p>
</div>
</div>
);
}
// Access Denied state
if (accessDenied?.denied) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="access-denied-state">
<div className="max-w-lg w-full bg-white rounded-2xl shadow-xl p-8 text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<ShieldX className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Access Denied</h2>
<p className="text-gray-600 mb-6 leading-relaxed">
{accessDenied.message}
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
Go to Dashboard
</Button>
</div>
</div>
</div>
);
}
// Not Found state
if (!request) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="not-found-state">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<FileText className="w-10 h-10 text-gray-400" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Dealer Claim Request Not Found</h2>
<p className="text-gray-600 mb-6">
The dealer claim request you're looking for doesn't exist or may have been deleted.
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
Go to Dashboard
</Button>
</div>
</div>
</div>
);
}
return (
<>
<div className="min-h-screen bg-gray-50" data-testid="dealer-claim-request-detail-page">
<div className="max-w-7xl mx-auto">
{/* Header Section */}
<RequestDetailHeader
request={request}
refreshing={refreshing}
onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh}
onShareSummary={handleShareSummary}
isInitiator={isInitiator}
// Dealer-claim module: Business logic for preparing SLA data
slaData={request?.summary?.sla || request?.sla || null}
isPaused={request?.pauseInfo?.isPaused || false}
/>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="dealer-claim-request-detail-tabs">
<div className="mb-4 sm:mb-6">
<TabsList className="grid grid-cols-3 sm:grid-cols-6 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
<TabsTrigger
value="overview"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-overview"
>
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Overview</span>
</TabsTrigger>
{isClosed && summaryDetails && (
<TabsTrigger
value="summary"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-summary"
>
<FileCheck className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Summary</span>
</TabsTrigger>
)}
<TabsTrigger
value="workflow"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-workflow"
>
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Workflow</span>
</TabsTrigger>
{showIOTab && (
<TabsTrigger
value="io"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-io"
>
<DollarSign className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">IO</span>
</TabsTrigger>
)}
<TabsTrigger
value="documents"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-documents"
>
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Docs</span>
</TabsTrigger>
<TabsTrigger
value="activity"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 col-span-1 sm:col-span-1"
data-testid="tab-activity"
>
<Activity className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Activity</span>
</TabsTrigger>
<TabsTrigger
value="worknotes"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 relative col-span-2 sm:col-span-1"
data-testid="tab-worknotes"
>
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Work Notes</span>
{unreadWorkNotes > 0 && (
<Badge
className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-[10px] flex items-center justify-center p-0"
data-testid="worknotes-unread-badge"
>
{unreadWorkNotes > 9 ? '9+' : unreadWorkNotes}
</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
{/* Main Layout */}
<div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
{/* Left Column: Tab content */}
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
<DealerClaimOverviewTab
request={request}
apiRequest={apiRequest}
currentUserId={currentUserId}
isInitiator={isInitiator}
needsClosure={needsClosure}
conclusionRemark={conclusionRemark}
setConclusionRemark={setConclusionRemark}
conclusionLoading={conclusionLoading}
conclusionSubmitting={conclusionSubmitting}
aiGenerated={aiGenerated}
handleGenerateConclusion={handleGenerateConclusion}
handleFinalizeConclusion={handleFinalizeConclusion}
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
/>
</TabsContent>
{isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab
summary={summaryDetails}
loading={loadingSummary}
onShare={handleShareSummary}
isInitiator={isInitiator}
/>
</TabsContent>
)}
<TabsContent value="workflow" className="mt-0">
<DealerClaimWorkflowTab
request={request}
user={user}
isInitiator={isInitiator}
onSkipApprover={(data) => {
if (!data.levelId) {
alert('Level ID not available');
return;
}
setSkipApproverData(data);
setShowSkipApproverModal(true);
}}
onRefresh={refreshDetails}
documentPolicy={documentPolicy}
/>
</TabsContent>
{showIOTab && (
<TabsContent value="io" className="mt-0">
<IOTab
request={request}
apiRequest={apiRequest}
onRefresh={refreshDetails}
/>
</TabsContent>
)}
<TabsContent value="documents" className="mt-0">
<DocumentsTab
request={request}
workNoteAttachments={workNoteAttachments}
uploadingDocument={uploadingDocument}
documentPolicy={documentPolicy}
triggerFileInput={triggerFileInput}
setPreviewDocument={setPreviewDocument}
downloadDocument={downloadDocument}
/>
</TabsContent>
<TabsContent value="activity" className="mt-0">
<ActivityTab request={request} />
</TabsContent>
<TabsContent value="worknotes" className="mt-0" forceMount={true} hidden={activeTab !== 'worknotes'}>
<WorkNotesTab
requestId={requestIdentifier}
requestTitle={request.title}
mergedMessages={mergedMessages}
setWorkNoteAttachments={setWorkNoteAttachments}
isInitiator={isInitiator}
isSpectator={isSpectator}
currentLevels={currentLevels}
onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/>
</TabsContent>
</div>
{/* Right Column: Quick Actions Sidebar */}
{activeTab !== 'worknotes' && (
<QuickActionsSidebar
request={request}
isInitiator={isInitiator}
isSpectator={isSpectator}
currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel}
onAddApprover={() => setShowAddApproverModal(true)}
onAddSpectator={() => setShowAddSpectatorModal(true)}
onApprove={() => setShowApproveModal(true)}
onReject={() => setShowRejectModal(true)}
onPause={handlePause}
onResume={handleResume}
onRetrigger={handleRetrigger}
summaryId={summaryId}
refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={currentUserId}
apiRequest={apiRequest}
/>
)}
</div>
</Tabs>
</div>
</div>
{/* Share Summary Modal */}
{showShareSummaryModal && summaryId && (
<ShareSummaryModal
isOpen={showShareSummaryModal}
onClose={() => setShowShareSummaryModal(false)}
summaryId={summaryId}
requestTitle={request?.title || 'N/A'}
onSuccess={() => {
refreshDetails();
setSharedRecipientsRefreshTrigger(prev => prev + 1);
}}
/>
)}
{/* Pause Modals */}
{showPauseModal && apiRequest?.requestId && (
<PauseModal
isOpen={showPauseModal}
onClose={() => setShowPauseModal(false)}
requestId={apiRequest.requestId}
levelId={currentApprovalLevel?.levelId || null}
onSuccess={handlePauseSuccess}
/>
)}
{showResumeModal && apiRequest?.requestId && (
<ResumeModal
isOpen={showResumeModal}
onClose={() => setShowResumeModal(false)}
requestId={apiRequest.requestId}
onSuccess={handleResumeSuccess}
/>
)}
{showRetriggerModal && apiRequest?.requestId && (
<RetriggerPauseModal
isOpen={showRetriggerModal}
onClose={() => setShowRetriggerModal(false)}
requestId={apiRequest.requestId}
approverName={request?.pauseInfo?.pausedBy?.name}
onSuccess={handleRetriggerSuccess}
/>
)}
{/* Modals */}
<RequestDetailModals
showApproveModal={showApproveModal}
showRejectModal={showRejectModal}
showAddApproverModal={showAddApproverModal}
showAddSpectatorModal={showAddSpectatorModal}
showSkipApproverModal={showSkipApproverModal}
showActionStatusModal={showActionStatusModal}
previewDocument={previewDocument}
documentError={documentError}
request={request}
skipApproverData={skipApproverData}
actionStatus={actionStatus}
existingParticipants={existingParticipants}
currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal}
setShowAddSpectatorModal={setShowAddSpectatorModal}
setShowSkipApproverModal={setShowSkipApproverModal}
setShowActionStatusModal={setShowActionStatusModal}
setPreviewDocument={setPreviewDocument}
setDocumentError={setDocumentError}
setSkipApproverData={setSkipApproverData}
setActionStatus={setActionStatus}
handleApproveConfirm={handleApproveConfirm}
handleRejectConfirm={handleRejectConfirm}
handleAddApprover={handleAddApprover}
handleAddSpectator={handleAddSpectator}
handleSkipApprover={handleSkipApprover}
downloadDocument={downloadDocument}
documentPolicy={documentPolicy}
/>
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators,
}}
/>
</>
);
}
/**
* Dealer Claim RequestDetail Component (Exported)
*/
export function DealerClaimRequestDetail(props: RequestDetailProps) {
return (
<RequestDetailErrorBoundary>
<DealerClaimRequestDetailInner {...props} />
</RequestDetailErrorBoundary>
);
}

167
src/flows.ts Normal file
View File

@ -0,0 +1,167 @@
/**
* Request Flow Registry
*
* Central registry for all request flow types.
* This provides a single import point for flow-specific components.
*
* LOCATION: src/flows.ts
*
* This file imports from flow folders at src/ level:
* - src/custom/
* - src/dealer-claim/
* - src/shared/
*/
import { RequestFlowType } from '@/utils/requestTypeUtils';
import { UserFilterType } from '@/utils/userFilterUtils';
// Import flow modules from src/ level
import * as CustomFlow from './custom';
import * as DealerClaimFlow from './dealer-claim';
import * as SharedComponents from './shared/components';
/**
* Flow registry mapping
* Maps RequestFlowType to their respective flow modules
*/
export const FlowRegistry = {
CUSTOM: CustomFlow,
DEALER_CLAIM: DealerClaimFlow,
} as const;
/**
* Get flow module for a given flow type
*/
export function getFlowModule(flowType: RequestFlowType) {
return FlowRegistry[flowType];
}
/**
* Get overview tab component for a flow type
*/
export function getOverviewTab(flowType: RequestFlowType) {
switch (flowType) {
case 'DEALER_CLAIM':
return DealerClaimFlow.DealerClaimOverviewTab;
case 'CUSTOM':
default:
return CustomFlow.CustomOverviewTab;
}
}
/**
* Get workflow tab component for a flow type
*/
export function getWorkflowTab(flowType: RequestFlowType) {
switch (flowType) {
case 'DEALER_CLAIM':
return DealerClaimFlow.DealerClaimWorkflowTab;
case 'CUSTOM':
default:
return CustomFlow.CustomWorkflowTab;
}
}
/**
* Get create request component for a flow type
*/
export function getCreateRequestComponent(flowType: RequestFlowType) {
switch (flowType) {
case 'DEALER_CLAIM':
return DealerClaimFlow.ClaimManagementWizard;
case 'CUSTOM':
default:
return CustomFlow.CustomCreateRequest;
}
}
/**
* Get RequestDetail screen component for a flow type
* Each flow has its own complete RequestDetail screen
*/
export function getRequestDetailScreen(flowType: RequestFlowType) {
switch (flowType) {
case 'DEALER_CLAIM':
return DealerClaimFlow.DealerClaimRequestDetail;
case 'CUSTOM':
default:
return CustomFlow.CustomRequestDetail;
}
}
/**
* Get Requests Filters component for a user filter type
* Each user type can have its own filter component
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + sort only)
* - STANDARD: Full filters (search + status + priority + template + sort)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardRequestsFilters;
}
}
/**
* Get Closed Requests Filters component for a user filter type
* Each user type can have its own filter component for closed requests
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + status + sort only, no priority or template)
* - STANDARD: Full filters (search + priority + status + template + sort)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a closed requests filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getClosedRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerClosedRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardClosedRequestsFilters;
}
}
/**
* Get User All Requests Filters component for a user filter type
* Each user type can have its own filter component for user all requests
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + status + initiator + approver + date range, no priority/template/department/sla)
* - STANDARD: Full filters (all filters including priority, template, department, and SLA compliance)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a user all requests filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getUserAllRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerUserAllRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardUserAllRequestsFilters;
}
}
// Re-export flow modules for direct access
export { CustomFlow, DealerClaimFlow, SharedComponents };
export type { RequestFlowType } from '@/utils/requestTypeUtils';
export type { UserFilterType } from '@/utils/userFilterUtils';

View File

@ -42,6 +42,18 @@ export function useConclusionRemark(
// State: Tracks if current conclusion was AI-generated (shows badge in UI) // State: Tracks if current conclusion was AI-generated (shows badge in UI)
const [aiGenerated, setAiGenerated] = useState(false); const [aiGenerated, setAiGenerated] = useState(false);
// State: Tracks number of AI generation attempts
const [generationAttempts, setGenerationAttempts] = useState(0);
// State: Tracks if AI generation failed (unable to generate)
const [generationFailed, setGenerationFailed] = useState(false);
// State: Tracks if max attempts (3 for success, 1 for fail) reached
const [maxAttemptsReached, setMaxAttemptsReached] = useState(false);
// State: Tracks number of AI generation failures
const [failureAttempts, setFailureAttempts] = useState(0);
/** /**
* Function: fetchExistingConclusion * Function: fetchExistingConclusion
* *
@ -50,26 +62,46 @@ export function useConclusionRemark(
* Use Case: When request is approved, final approver generates conclusion. * Use Case: When request is approved, final approver generates conclusion.
* Initiator needs to review and finalize it before closing request. * Initiator needs to review and finalize it before closing request.
* *
* Optimization: Check request object first before making API call
* Process: * Process:
* 1. Dynamically import conclusion API service * 1. Check if conclusion data is already in request object
* 2. Fetch conclusion by request ID * 2. If not available, fetch from API
* 3. Load into state if exists * 3. Load into state if exists
* 4. Mark as AI-generated if applicable * 4. Mark as AI-generated if applicable
*/ */
const fetchExistingConclusion = async () => { const fetchExistingConclusion = async () => {
// Optimization: Check if conclusion data is already in request object
// Request detail response includes conclusionRemark and aiGeneratedConclusion fields
const existingConclusion = request?.conclusionRemark || request?.conclusion_remark;
const existingAiConclusion = request?.aiGeneratedConclusion || request?.ai_generated_conclusion;
if (existingConclusion || existingAiConclusion) {
// Use data from request object - no API call needed
setConclusionRemark(existingConclusion || existingAiConclusion);
setAiGenerated(!!existingAiConclusion);
return;
}
// Only fetch from API if not available in request object
// This handles cases where request object might not have been refreshed yet
try { try {
// Lazy load: Import conclusion API only when needed // Lazy load: Import conclusion API only when needed
const { getConclusion } = await import('@/services/conclusionApi'); const { getConclusion } = await import('@/services/conclusionApi');
// API Call: Fetch existing conclusion // API Call: Fetch existing conclusion (returns null if not found)
const result = await getConclusion(request.requestId || requestIdentifier); const result = await getConclusion(request.requestId || requestIdentifier);
if (result && result.aiGeneratedRemark) { if (result && (result.aiGeneratedRemark || result.finalRemark)) {
// Load: Set the AI-generated or final remark // Load: Set the AI-generated or final remark
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark); // Handle null values by providing empty string fallback
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark || '');
setAiGenerated(!!result.aiGeneratedRemark); setAiGenerated(!!result.aiGeneratedRemark);
} }
} catch (err) { } catch (err) {
// Only log non-404 errors (404 is handled gracefully in API)
if ((err as any)?.response?.status !== 404) {
console.error('[useConclusionRemark] Error fetching conclusion:', err);
}
// No conclusion yet - this is expected for newly approved requests // No conclusion yet - this is expected for newly approved requests
} }
}; };
@ -93,8 +125,12 @@ export function useConclusionRemark(
* 5. Handle errors silently (user can type manually) * 5. Handle errors silently (user can type manually)
*/ */
const handleGenerateConclusion = async () => { const handleGenerateConclusion = async () => {
// Safety check: Prevent generation if max attempts already reached
if (maxAttemptsReached) return;
try { try {
setConclusionLoading(true); setConclusionLoading(true);
setGenerationFailed(false);
// Lazy load: Import conclusion API // Lazy load: Import conclusion API
const { generateConclusion } = await import('@/services/conclusionApi'); const { generateConclusion } = await import('@/services/conclusionApi');
@ -102,14 +138,74 @@ export function useConclusionRemark(
// API Call: Generate AI conclusion based on request data // API Call: Generate AI conclusion based on request data
const result = await generateConclusion(request.requestId || requestIdentifier); const result = await generateConclusion(request.requestId || requestIdentifier);
const newAttempts = generationAttempts + 1;
setGenerationAttempts(newAttempts);
// Check for "unable to generate" or similar keywords in proper response
const isUnableToGenerate = !result?.aiGeneratedRemark ||
result.aiGeneratedRemark.toLowerCase().includes('unable to generate') ||
result.aiGeneratedRemark.toLowerCase().includes('sorry');
if (isUnableToGenerate) {
const newFailures = failureAttempts + 1;
setFailureAttempts(newFailures);
if (newFailures >= 2) {
setMaxAttemptsReached(true);
setActionStatus?.({
success: false,
title: 'AI Generation Limit Reached',
message: "We're unable to process a conclusion remark at this time after 2 attempts. Please proceed with a manual approach using the editor below."
});
} else {
setActionStatus?.({
success: false,
title: 'System Note',
message: "We're unable to process a conclusion remark at the moment. You have one more attempt remaining, or you can proceed manually."
});
}
setShowActionStatusModal?.(true);
setConclusionRemark(result?.aiGeneratedRemark || '');
setAiGenerated(false);
return;
}
// Success: Load AI-generated remark // Success: Load AI-generated remark
setConclusionRemark(result.aiGeneratedRemark); setConclusionRemark(result.aiGeneratedRemark);
setAiGenerated(true); setAiGenerated(true);
setFailureAttempts(0); // Reset failures on success
// Limit to 2 successful attempts
if (newAttempts >= 2) {
setMaxAttemptsReached(true);
setActionStatus?.({
success: true,
title: 'Maximum Attempts Reached',
message: "You've reached the maximum of 2 regeneration attempts. Feel free to manually edit the current suggestion to fit your specific needs."
});
setShowActionStatusModal?.(true);
}
} catch (err) { } catch (err) {
// Fail silently: User can write conclusion manually
console.error('[useConclusionRemark] AI generation failed:', err); console.error('[useConclusionRemark] AI generation failed:', err);
setConclusionRemark(''); const newFailures = failureAttempts + 1;
setFailureAttempts(newFailures);
setAiGenerated(false); setAiGenerated(false);
if (newFailures >= 2) {
setMaxAttemptsReached(true);
setActionStatus?.({
success: false,
title: 'System Note',
message: "We're unable to process your request at the moment. Since the maximum of 2 attempts is reached, please proceed with a manual approach."
});
} else {
setActionStatus?.({
success: false,
title: 'System Note',
message: "We're unable to process your request at the moment. You have one more attempt remaining, or you can proceed manually."
});
}
setShowActionStatusModal?.(true);
} finally { } finally {
setConclusionLoading(false); setConclusionLoading(false);
} }
@ -218,16 +314,36 @@ export function useConclusionRemark(
}; };
/** /**
* Effect: Auto-fetch existing conclusion when request becomes approved or rejected * Effect: Auto-load existing conclusion when request becomes approved, rejected, or closed
* *
* Trigger: When request status changes to "approved" or "rejected" and user is initiator * Trigger: When request status changes to "approved", "rejected", or "closed" and user is initiator
* Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected) * Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected)
*
* Optimization:
* 1. First check if conclusion data is already in request object (no API call needed)
* 2. Only fetch from API if not available in request object
*/ */
useEffect(() => { useEffect(() => {
if ((request?.status === 'approved' || request?.status === 'rejected') && isInitiator && !conclusionRemark) { const status = request?.status?.toLowerCase();
const shouldLoad = (status === 'approved' || status === 'rejected' || status === 'closed')
&& isInitiator
&& !conclusionRemark;
if (!shouldLoad) return;
// Check if conclusion data is already in request object
const existingConclusion = request?.conclusionRemark || request?.conclusion_remark;
const existingAiConclusion = request?.aiGeneratedConclusion || request?.ai_generated_conclusion;
if (existingConclusion || existingAiConclusion) {
// Use data from request object - no API call needed
setConclusionRemark(existingConclusion || existingAiConclusion);
setAiGenerated(!!existingAiConclusion);
} else {
// Only fetch from API if not available in request object
fetchExistingConclusion(); fetchExistingConclusion();
} }
}, [request?.status, isInitiator]); }, [request?.status, request?.conclusionRemark, request?.aiGeneratedConclusion, isInitiator, conclusionRemark]);
return { return {
conclusionRemark, conclusionRemark,
@ -236,7 +352,10 @@ export function useConclusionRemark(
conclusionSubmitting, conclusionSubmitting,
aiGenerated, aiGenerated,
handleGenerateConclusion, handleGenerateConclusion,
handleFinalizeConclusion handleFinalizeConclusion,
generationAttempts,
generationFailed,
maxAttemptsReached
}; };
} }

View File

@ -163,9 +163,9 @@ export function useCreateRequestForm(
}); });
// Load system policy // Load system policy
const workflowConfigs = await getPublicConfigurations('WORKFLOW_SHARING'); const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const tatConfigs = await getPublicConfigurations('TAT_SETTINGS'); const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...workflowConfigs, ...tatConfigs]; const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {}; const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => { allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue; configMap[c.configKey] = c.configValue;

View File

@ -85,6 +85,10 @@ export function useModalManager(
// API Call: Submit approval // API Call: Submit approval
await approveLevel(requestIdentifier, levelId, description || ''); await approveLevel(requestIdentifier, levelId, description || '');
// Small delay to ensure backend has fully processed the approval and updated the status
// This is especially important for additional approvers where the workflow moves to the next step
await new Promise(resolve => setTimeout(resolve, 500));
// Refresh: Update UI with new approval status // Refresh: Update UI with new approval status
await refreshDetails(); await refreshDetails();

View File

@ -1,5 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import workflowApi, { getPauseDetails } from '@/services/workflowApi'; import workflowApi, { getPauseDetails } from '@/services/workflowApi';
import apiClient from '@/services/authApi';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase'; import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { getSocket } from '@/utils/socket'; import { getSocket } from '@/utils/socket';
@ -218,15 +219,67 @@ export function useRequestDetails(
: []; : [];
/** /**
* Fetch: Get pause details if request is paused * Fetch: Get pause details only if request is actually paused
* This is needed to show resume/retrigger buttons correctly * Use request-level isPaused field from workflow response
*/ */
let pauseInfo = null; let pauseInfo = null;
const isPaused = (wf as any).isPaused || false;
if (isPaused) {
try { try {
pauseInfo = await getPauseDetails(wf.requestId); pauseInfo = await getPauseDetails(wf.requestId);
} catch (error) { } catch (error) {
// Pause info not available or request not paused - ignore // Pause info not available - ignore
console.debug('Pause details not available:', error); }
}
/**
* Fetch: Get claim details if this is a claim management request
*/
let claimDetails = null;
let proposalDetails = null;
let completionDetails = null;
let internalOrder = null;
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try {
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null;
// New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking;
(claimDetails as any).invoice = invoice;
(claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses;
}
// Extracted details processed
} else {
console.warn('[useRequestDetails] No claimData found in response');
}
} catch (error: any) {
// Claim details not available - request might not be fully initialized yet
console.error('[useRequestDetails] Error fetching claim details:', {
error: error?.message || error,
status: error?.response?.status,
statusText: error?.response?.statusText,
responseData: error?.response?.data,
requestId: wf.requestId,
});
}
} }
/** /**
@ -242,12 +295,16 @@ export function useRequestDetails(
description: wf.description, description: wf.description,
status: statusMap(wf.status), status: statusMap(wf.status),
priority: (wf.priority || '').toString().toLowerCase(), priority: (wf.priority || '').toString().toLowerCase(),
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
approvalFlow, approvalFlow,
approvals, // Raw approvals for SLA calculations approvals, // Raw approvals for SLA calculations
participants, participants,
documents: mappedDocuments, documents: mappedDocuments,
spectators, spectators,
summary, // Backend-provided SLA summary summary, // Backend-provided SLA summary
// Ensure SLA is available at root level for RequestDetailHeader
// Backend provides full SLA in summary.sla with all required fields
sla: summary?.sla || wf.sla || null,
initiator: { initiator: {
name: wf.initiator?.displayName || wf.initiator?.email, name: wf.initiator?.displayName || wf.initiator?.email,
role: wf.initiator?.designation || undefined, role: wf.initiator?.designation || undefined,
@ -266,6 +323,16 @@ export function useRequestDetails(
conclusionRemark: wf.conclusionRemark || null, conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null, closureDate: wf.closureDate || null,
pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons
// Claim management specific data
claimDetails: claimDetails || null,
proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null,
internalOrder: internalOrder || null,
// New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(updatedRequest); setApiRequest(updatedRequest);
@ -432,13 +499,61 @@ export function useRequestDetails(
}) })
: []; : [];
// Fetch pause details // Fetch pause details only if request is actually paused
// Use request-level isPaused field from workflow response
let pauseInfo = null; let pauseInfo = null;
const isPaused = (wf as any).isPaused || false;
if (isPaused) {
try { try {
pauseInfo = await getPauseDetails(wf.requestId); pauseInfo = await getPauseDetails(wf.requestId);
} catch (error) { } catch (error) {
// Pause info not available or request not paused - ignore // Pause info not available - ignore
console.debug('Pause details not available:', error); }
}
/**
* Fetch: Get claim details if this is a claim management request
*/
let claimDetails = null;
let proposalDetails = null;
let completionDetails = null;
let internalOrder = null;
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try {
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null;
// New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking;
(claimDetails as any).invoice = invoice;
(claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses;
}
// Initial load - Extracted details processed
}
} catch (error: any) {
// Claim details not available - request might not be fully initialized yet
console.error('[useRequestDetails] Initial load - Error fetching claim details:', {
error: error?.message || error,
status: error?.response?.status,
requestId: wf.requestId,
});
}
} }
// Build complete request object // Build complete request object
@ -449,6 +564,7 @@ export function useRequestDetails(
description: wf.description, description: wf.description,
priority, priority,
status: statusMap(wf.status), status: statusMap(wf.status),
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
summary, summary,
initiator: { initiator: {
name: wf.initiator?.displayName || wf.initiator?.email, name: wf.initiator?.displayName || wf.initiator?.email,
@ -472,6 +588,16 @@ export function useRequestDetails(
conclusionRemark: wf.conclusionRemark || null, conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null, closureDate: wf.closureDate || null,
pauseInfo: pauseInfo || null, pauseInfo: pauseInfo || null,
// Claim management specific data
claimDetails: claimDetails || null,
proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null,
internalOrder: internalOrder || null,
// New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(mapped); setApiRequest(mapped);
@ -639,35 +765,26 @@ export function useRequestDetails(
const socket = getSocket(); const socket = getSocket();
if (!socket) { if (!socket) {
console.warn('[useRequestDetails] Socket not available');
return; return;
} }
console.log('[useRequestDetails] Setting up socket listener for request:', apiRequest.requestId);
/** /**
* Handler: Request updated by another user * Handler: Request updated by another user
* Silently refresh to show latest changes * Silently refresh to show latest changes
*/ */
const handleRequestUpdated = (data: any) => { const handleRequestUpdated = (data: any) => {
console.log('[useRequestDetails] 📡 Received request:updated event:', data);
// Verify this update is for the current request // Verify this update is for the current request
if (data?.requestId === apiRequest.requestId || data?.requestNumber === requestIdentifier) { if (data?.requestId === apiRequest.requestId || data?.requestNumber === requestIdentifier) {
console.log('[useRequestDetails] 🔄 Request updated remotely, refreshing silently...');
// Silent refresh - no loading state, no user interruption // Silent refresh - no loading state, no user interruption
refreshDetails(); refreshDetails();
} else {
console.log('[useRequestDetails] ⚠️ Event for different request, ignoring');
} }
}; };
// Register listener // Register listener
socket.on('request:updated', handleRequestUpdated); socket.on('request:updated', handleRequestUpdated);
console.log('[useRequestDetails] ✅ Registered listener for request:updated');
// Cleanup on unmount // Cleanup on unmount
return () => { return () => {
console.log('[useRequestDetails] 🧹 Cleaning up socket listener');
socket.off('request:updated', handleRequestUpdated); socket.off('request:updated', handleRequestUpdated);
}; };
}, [requestIdentifier, apiRequest, refreshDetails]); }, [requestIdentifier, apiRequest, refreshDetails]);

View File

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

View File

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

View File

@ -327,7 +327,7 @@ export function CreateTemplate() {
placeholder={approver.tatType === 'days' ? '1' : '24'} placeholder={approver.tatType === 'days' ? '1' : '24'}
onChange={(e) => { onChange={(e) => {
const val = parseInt(e.target.value) || 0; const val = parseInt(e.target.value) || 0;
const max = approver.tatType === 'days' ? 7 : 24; // const max = approver.tatType === 'days' ? 7 : 24;
// Optional: strict clamping or just allow typing and validate later // Optional: strict clamping or just allow typing and validate later
// For better UX, let's allow typing but validate in isFormValid // For better UX, let's allow typing but validate in isFormValid
// But prevent entering negative numbers // But prevent entering negative numbers

View File

@ -11,6 +11,7 @@ import { Pagination } from '@/components/common/Pagination';
import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers';
import { formatDate, formatDateTime } from '../utils/formatters'; import { formatDate, formatDateTime } from '../utils/formatters';
import { formatHoursMinutes } from '@/utils/slaTracker'; import { formatHoursMinutes } from '@/utils/slaTracker';
import { navigateToRequest } from '@/utils/requestNavigation';
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types'; import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
interface ApproverPerformanceRequestListProps { interface ApproverPerformanceRequestListProps {
@ -68,7 +69,15 @@ export function ApproverPerformanceRequestList({
<Card <Card
key={request.requestId} key={request.requestId}
className="hover:shadow-md transition-shadow cursor-pointer" className="hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate(`/request/${request.requestId}`)} onClick={() => {
navigateToRequest({
requestId: request.requestId,
requestTitle: request.title,
status: request.status,
request: request,
navigate,
});
}}
data-testid={`request-card-${request.requestId}`} data-testid={`request-card-${request.requestId}`}
> >
<CardContent className="p-4"> <CardContent className="p-4">
@ -157,7 +166,13 @@ export function ApproverPerformanceRequestList({
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate(`/request/${request.requestId}`); navigateToRequest({
requestId: request.requestId,
requestTitle: request.title,
status: request.status,
request: request,
navigate,
});
}} }}
data-testid="view-request-button" data-testid="view-request-button"
> >

View File

@ -1,13 +1,30 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { LogIn } from 'lucide-react'; import { LogIn } from 'lucide-react';
import { ReLogo } from '@/assets'; import { ReLogo, LandingPageImage } from '@/assets';
// import { initiateTanflowLogin } from '@/services/tanflowAuth';
export function Auth() { export function Auth() {
const { login, isLoading, error } = useAuth(); const { login, isLoading, error } = useAuth();
const [tanflowLoading] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const handleSSOLogin = async () => { // Preload the background image
useEffect(() => {
const img = new Image();
img.src = LandingPageImage;
img.onload = () => {
setImageLoaded(true);
};
// If image is already cached, trigger load immediately
if (img.complete) {
setImageLoaded(true);
}
}, []);
const handleOKTALogin = async () => {
// Clear any existing session data // Clear any existing session data
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
@ -16,7 +33,7 @@ export function Auth() {
await login(); await login();
} catch (loginError) { } catch (loginError) {
console.error('========================================'); console.error('========================================');
console.error('LOGIN ERROR'); console.error('OKTA LOGIN ERROR');
console.error('Error details:', loginError); console.error('Error details:', loginError);
console.error('Error message:', (loginError as Error)?.message); console.error('Error message:', (loginError as Error)?.message);
console.error('Error stack:', (loginError as Error)?.stack); console.error('Error stack:', (loginError as Error)?.stack);
@ -24,16 +41,48 @@ export function Auth() {
} }
}; };
/* const handleTanflowLogin = () => {
// Clear any existing session data
localStorage.clear();
sessionStorage.clear();
setTanflowLoading(true);
try {
initiateTanflowLogin();
} catch (loginError) {
console.error('========================================');
console.error('TANFLOW LOGIN ERROR');
console.error('Error details:', loginError);
setTanflowLoading(false);
}
}; */
if (error) { if (error) {
console.error('Auth0 Error in Auth Component:', { console.error('Auth Error in Auth Component:', {
message: error.message, message: error.message,
error: error error: error
}); });
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4"> <div
<Card className="w-full max-w-md shadow-xl"> className="min-h-screen flex items-center justify-center p-4 relative"
style={{
backgroundImage: imageLoaded ? `url(${LandingPageImage})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
transition: 'background-image 0.3s ease-in-out'
}}
>
{/* Fallback background while image loads */}
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 to-slate-800"></div>
)}
{/* Overlay for better readability */}
<div className="absolute inset-0 bg-black/40"></div>
<Card className="w-full max-w-md shadow-xl relative z-10 bg-black backdrop-blur-sm border-gray-800">
<CardHeader className="space-y-1 text-center pb-6"> <CardHeader className="space-y-1 text-center pb-6">
<div className="flex flex-col items-center justify-center mb-4"> <div className="flex flex-col items-center justify-center mb-4">
<img <img
@ -41,21 +90,22 @@ export function Auth() {
alt="Royal Enfield Logo" alt="Royal Enfield Logo"
className="h-10 w-auto max-w-[168px] object-contain mb-2" className="h-10 w-auto max-w-[168px] object-contain mb-2"
/> />
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p> <p className="text-xs text-gray-300 text-center truncate">Approval Portal</p>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{error && ( {error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg"> <div className="bg-red-900/50 border border-red-700 text-red-200 px-4 py-3 rounded-lg">
<p className="text-sm font-medium">Authentication Error</p> <p className="text-sm font-medium">Authentication Error</p>
<p className="text-sm">{error.message}</p> <p className="text-sm">{error.message}</p>
</div> </div>
)} )}
<div className="space-y-3">
<Button <Button
onClick={handleSSOLogin} onClick={handleOKTALogin}
disabled={isLoading} disabled={isLoading || tanflowLoading}
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white" className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
size="lg" size="lg"
> >
@ -69,14 +119,45 @@ export function Auth() {
) : ( ) : (
<> <>
<LogIn className="mr-2 h-5 w-5" /> <LogIn className="mr-2 h-5 w-5" />
SSO Login RE Employee Login
</> </>
)} )}
</Button> </Button>
{/*
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-700"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-gray-900 px-2 text-gray-400">Or</span>
</div>
</div>
<div className="text-center text-sm text-gray-500 mt-4"> <Button
onClick={handleTanflowLogin}
disabled={isLoading || tanflowLoading}
className="w-full h-12 text-base font-semibold bg-indigo-600 hover:bg-indigo-700 text-white"
size="lg"
>
{tanflowLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Redirecting...
</>
) : (
<>
<Shield className="mr-2 h-5 w-5" />
Dealer Login
</>
)}
</Button> */}
</div>
<div className="text-center text-sm text-gray-400 mt-4">
<p>Secure Single Sign-On</p> <p>Secure Single Sign-On</p>
<p className="text-xs mt-1">Powered by Auth0</p> <p className="text-xs mt-1 text-gray-500">Choose your authentication provider</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Auth } from './Auth'; import { Auth } from './Auth';
import { AuthCallback } from './AuthCallback'; import { AuthCallback } from './AuthCallback';
import { TanflowCallback } from './TanflowCallback';
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo'; import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
import App from '../../App'; import App from '../../App';
@ -10,7 +11,8 @@ export function AuthenticatedApp() {
const [showDebugInfo, setShowDebugInfo] = useState(false); const [showDebugInfo, setShowDebugInfo] = useState(false);
// Check if we're on callback route (after all hooks are called) // Check if we're on callback route (after all hooks are called)
const isCallbackRoute = typeof window !== 'undefined' && window.location.pathname === '/login/callback'; const isCallbackRoute = typeof window !== 'undefined' &&
window.location.pathname === '/login/callback';
const handleLogout = async () => { const handleLogout = async () => {
try { try {
@ -39,7 +41,35 @@ export function AuthenticatedApp() {
}, [isAuthenticated, isLoading, error, user]); }, [isAuthenticated, isLoading, error, user]);
// Always show callback loader when on callback route (after all hooks) // Always show callback loader when on callback route (after all hooks)
// Detect provider from sessionStorage to show appropriate callback component
if (isCallbackRoute) { if (isCallbackRoute) {
// Check if this is a logout redirect (no code, no error)
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const hasCode = urlParams?.get('code');
const hasError = urlParams?.get('error');
// If no code and no error, it's a logout redirect - redirect immediately
if (!hasCode && !hasError) {
console.log('🚪 AuthenticatedApp: Logout redirect detected, redirecting to home');
const logoutParams = new URLSearchParams();
logoutParams.set('tanflow_logged_out', 'true');
logoutParams.set('logout', Date.now().toString());
window.location.replace(`/?${logoutParams.toString()}`);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-slate-900 border-t-transparent mx-auto mb-4"></div>
<p className="text-gray-600">Redirecting...</p>
</div>
</div>
);
}
const authProvider = typeof window !== 'undefined' ? sessionStorage.getItem('auth_provider') : null;
if (authProvider === 'tanflow') {
return <TanflowCallback />;
}
// Default to OKTA callback (or if provider not set yet)
return <AuthCallback />; return <AuthCallback />;
} }

View File

@ -0,0 +1,301 @@
/**
* Tanflow OAuth Callback Handler
* Handles the redirect from Tanflow SSO after authentication
*/
import { useEffect, useState, useRef } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { exchangeTanflowCodeForTokens } from '@/services/tanflowAuth';
import { getCurrentUser } from '@/services/authApi';
import { TokenManager } from '@/utils/tokenManager';
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { ReLogo } from '@/assets';
export function TanflowCallback() {
const { isAuthenticated, isLoading, error, user } = useAuth();
const [authStep, setAuthStep] = useState<'exchanging' | 'fetching' | 'complete' | 'error'>('exchanging');
const [errorMessage, setErrorMessage] = useState<string>('');
const callbackProcessedRef = useRef(false);
useEffect(() => {
// Determine current authentication step based on state
if (error) {
setAuthStep('error');
return;
}
if (isLoading) {
const urlParams = new URLSearchParams(window.location.search);
const hasCode = urlParams.get('code');
if (hasCode && !user) {
setAuthStep('exchanging');
} else if (user && !isAuthenticated) {
setAuthStep('fetching');
} else {
setAuthStep('exchanging');
}
} else if (user && isAuthenticated) {
setAuthStep('complete');
// If already authenticated, redirect immediately
// This handles the case where auth state was set before this component rendered
setTimeout(() => {
window.location.href = '/';
}, 1000);
}
}, [isAuthenticated, isLoading, error, user]);
// Handle Tanflow callback
useEffect(() => {
// Only process if we're on the callback route
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
return;
}
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const errorParam = urlParams.get('error');
// SIMPLIFIED: If no code and no error, it's a logout redirect - redirect immediately
// Tanflow logout redirects back to /login/callback without any parameters
if (!code && !errorParam) {
console.log('🚪 Logout redirect detected: no code, no error - redirecting to home immediately');
callbackProcessedRef.current = true;
// Redirect to home with logout flags
const logoutParams = new URLSearchParams();
logoutParams.set('tanflow_logged_out', 'true');
logoutParams.set('logout', Date.now().toString());
const redirectUrl = `/?${logoutParams.toString()}`;
console.log('🚪 Redirecting to:', redirectUrl);
window.location.replace(redirectUrl);
return;
}
// Check if this is a Tanflow callback
const authProvider = sessionStorage.getItem('auth_provider');
if (authProvider !== 'tanflow') {
// Not a Tanflow callback, let AuthContext handle it
return;
}
const handleCallback = async () => {
callbackProcessedRef.current = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const errorParam = urlParams.get('error');
// Clean URL immediately
window.history.replaceState({}, document.title, '/login/callback');
// Check for errors from Tanflow
if (errorParam) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
// Validate state
const storedState = sessionStorage.getItem('tanflow_auth_state');
if (state && state !== storedState) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
if (!code) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
try {
setAuthStep('exchanging');
// Exchange code for tokens (this stores tokens in TokenManager)
const tokenData = await exchangeTanflowCodeForTokens(code, state || '');
// Clear state but keep provider flag for logout detection
sessionStorage.removeItem('tanflow_auth_state');
// Keep auth_provider in sessionStorage so logout can detect which provider to use
// This will be cleared during logout
setAuthStep('fetching');
// Fetch user profile (tokenData already has user, but fetch to ensure it's current)
const userData = tokenData.user || await getCurrentUser();
if (userData) {
// Store user data in TokenManager (already stored by exchangeTanflowCodeForTokens, but ensure it's set)
TokenManager.setUserData(userData);
// Show success message briefly
setAuthStep('complete');
// Clean URL and do full page reload to ensure AuthContext checks auth status
// This is necessary because AuthContext skips auth check on /login/callback route
// After reload, AuthContext will check tokens and set isAuthenticated/user properly
setTimeout(() => {
window.history.replaceState({}, document.title, '/');
// Use window.location.href for full page reload to trigger AuthContext initialization
window.location.href = '/';
}, 1000);
} else {
throw new Error('User data not received');
}
} catch (err: any) {
console.error('Tanflow callback error:', err);
setAuthStep('error');
setErrorMessage(err.message || 'Authentication failed');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
}
};
handleCallback();
}, []);
const getLoadingMessage = () => {
switch (authStep) {
case 'exchanging':
return 'Exchanging authorization code...';
case 'fetching':
return 'Fetching your profile...';
case 'complete':
return 'Authentication successful!';
case 'error':
return 'Authentication failed';
default:
return 'Completing authentication...';
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiMxZTIxMmQiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMzAiLz48L2c+PC9nPjwvc3ZnPg==')] opacity-20"></div>
<div className="relative z-10 text-center px-4 max-w-md w-full">
{/* Logo/Brand Section */}
<div className="mb-8">
<div className="flex flex-col items-center justify-center">
<img
src={ReLogo}
alt="Royal Enfield Logo"
className="h-10 w-auto max-w-[168px] object-contain mb-2"
/>
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
</div>
</div>
{/* Main Loader Card */}
<div className="bg-white/10 backdrop-blur-xl rounded-2xl p-8 shadow-2xl border border-white/20">
{/* Status Icon */}
<div className="mb-6 flex justify-center">
{authStep === 'error' ? (
<div className="relative">
<div className="absolute inset-0 animate-ping opacity-75">
<AlertCircle className="w-16 h-16 text-red-500" />
</div>
<AlertCircle className="w-16 h-16 text-red-500 relative" />
</div>
) : authStep === 'complete' ? (
<div className="relative">
<div className="absolute inset-0 animate-ping opacity-75">
<CheckCircle2 className="w-16 h-16 text-green-500" />
</div>
<CheckCircle2 className="w-16 h-16 text-green-500 relative" />
</div>
) : (
<div className="relative">
<Loader2 className="w-16 h-16 animate-spin text-re-red" />
<div className="absolute inset-0 border-4 rounded-full border-re-red/20"></div>
<div className="absolute inset-0 border-4 border-transparent border-t-re-red rounded-full animate-spin"></div>
</div>
)}
</div>
{/* Loading Message */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">
{authStep === 'complete' ? 'Welcome Back!' : authStep === 'error' ? 'Authentication Error' : 'Authenticating'}
</h2>
<p className="text-slate-300 text-sm">{getLoadingMessage()}</p>
</div>
{/* Progress Steps */}
{authStep !== 'error' && (
<div className="space-y-3 mb-6">
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'exchanging' ? 'text-white' : 'text-slate-400'}`}>
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'exchanging' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
<span>Validating credentials</span>
</div>
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'fetching' ? 'text-white' : 'text-slate-400'}`}>
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'fetching' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
<span>Loading your profile</span>
</div>
{authStep === 'complete' && (
<div className="flex items-center gap-3 text-sm transition-all duration-500 text-white">
<div className="w-2 h-2 rounded-full transition-all duration-500 bg-green-500"></div>
<span>Setting up your session</span>
</div>
)}
</div>
)}
{/* Error Message */}
{authStep === 'error' && errorMessage && (
<div className="mt-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-red-400 text-sm">{errorMessage}</p>
<button
onClick={() => {
window.location.href = '/';
}}
className="mt-4 text-sm text-red-400 hover:text-red-300 underline"
>
Return to login
</button>
</div>
)}
{/* Animated Progress Bar */}
{authStep !== 'error' && authStep !== 'complete' && (
<div className="mt-6">
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
<div
className="h-full bg-re-red rounded-full animate-pulse"
style={{
animation: 'progress 2s ease-in-out infinite',
}}
></div>
</div>
<style>{`
@keyframes progress {
0%, 100% { width: 20%; }
50% { width: 80%; }
}
`}</style>
</div>
)}
</div>
{/* Footer Text */}
<p className="mt-6 text-slate-500 text-xs">
{authStep === 'complete' ? 'Loading dashboard...' : 'Please wait while we secure your session'}
</p>
</div>
{/* Animated Background Elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse delay-1000"></div>
</div>
</div>
);
}

View File

@ -1,8 +1,7 @@
import { useCallback, useRef, useEffect } from 'react'; import { useCallback, useRef, useEffect, useMemo } from 'react';
// Components // Components
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader'; import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
import { ClosedRequestsList } from './components/ClosedRequestsList'; import { ClosedRequestsList } from './components/ClosedRequestsList';
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty'; import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination'; import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
@ -14,6 +13,11 @@ import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
// Types // Types
import type { ClosedRequestsProps } from './types/closedRequests.types'; import type { ClosedRequestsProps } from './types/closedRequests.types';
// Utils & Factory
import { getUserFilterType } from '@/utils/userFilterUtils';
import { getClosedRequestsFilters } from '@/flows';
import { TokenManager } from '@/utils/tokenManager';
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
// Data fetching hook // Data fetching hook
const closedRequests = useClosedRequests({ itemsPerPage: 10 }); const closedRequests = useClosedRequests({ itemsPerPage: 10 });
@ -23,10 +27,29 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current = closedRequests.fetchRequests; fetchRef.current = closedRequests.fetchRequests;
const filters = useClosedRequestsFilters(); const filters = useClosedRequestsFilters();
// Get user filter type and corresponding filter component (plug-and-play pattern)
const userFilterType = useMemo(() => {
try {
const userData = TokenManager.getUserData();
return getUserFilterType(userData);
} catch (error) {
console.error('[ClosedRequests] Error getting user filter type:', error);
return 'STANDARD' as const;
}
}, []);
// Get the appropriate filter component based on user type
const ClosedRequestsFiltersComponent = useMemo(() => {
return getClosedRequestsFilters(userFilterType);
}, [userFilterType]);
const isDealer = userFilterType === 'DEALER';
const prevFiltersRef = useRef({ const prevFiltersRef = useRef({
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
@ -38,13 +61,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current(storedPage, { fetchRef.current(storedPage, {
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, // Only include priority and templateType filters if user is not a dealer
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
hasInitialFetchRun.current = true; hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount }, [isDealer]); // Re-fetch if dealer status changes
// Track filter changes and refetch // Track filter changes and refetch
useEffect(() => { useEffect(() => {
@ -55,6 +80,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
prev.searchTerm !== filters.searchTerm || prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter || prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter || prev.priorityFilter !== filters.priorityFilter ||
prev.templateTypeFilter !== filters.templateTypeFilter ||
prev.sortBy !== filters.sortBy || prev.sortBy !== filters.sortBy ||
prev.sortOrder !== filters.sortOrder; prev.sortOrder !== filters.sortOrder;
@ -67,6 +93,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
@ -76,6 +103,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}; };
@ -83,7 +111,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.sortBy, filters.sortOrder]); }, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
// Page change handler // Page change handler
const handlePageChange = useCallback( const handlePageChange = useCallback(
@ -94,6 +122,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
@ -108,6 +137,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
@ -123,17 +153,25 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />
{/* Filters */} {/* Filters - Plug-and-play pattern */}
<ClosedRequestsFiltersComponent <ClosedRequestsFiltersComponent
searchTerm={filters.searchTerm} searchTerm={filters.searchTerm}
priorityFilter={filters.priorityFilter} priorityFilter={filters.priorityFilter}
statusFilter={filters.statusFilter} statusFilter={filters.statusFilter}
templateTypeFilter={filters.templateTypeFilter}
sortBy={filters.sortBy} sortBy={filters.sortBy}
sortOrder={filters.sortOrder} sortOrder={filters.sortOrder}
activeFiltersCount={filters.activeFiltersCount} activeFiltersCount={
isDealer
? // For dealers: only count search and status (closure type)
[filters.searchTerm, filters.statusFilter !== 'all' ? filters.statusFilter : null].filter(Boolean).length
: // For standard users: count all filters
filters.activeFiltersCount
}
onSearchChange={filters.setSearchTerm} onSearchChange={filters.setSearchTerm}
onPriorityChange={filters.setPriorityFilter} onPriorityChange={filters.setPriorityFilter}
onStatusChange={filters.setStatusFilter} onStatusChange={filters.setStatusFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter}
onSortByChange={filters.setSortBy} onSortByChange={filters.setSortBy}
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')} onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
onClearFilters={filters.clearFilters} onClearFilters={filters.clearFilters}

View File

@ -61,6 +61,32 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
> >
{request.priority} {request.priority}
</Badge> </Badge>
{/* Template Type Badge */}
{(() => {
const templateType = request.templateType || '';
const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType
let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template';
}
return (
<Badge
variant="outline"
className={`${templateColor} text-xs px-2.5 py-0.5 shrink-0 hidden md:inline-flex`}
data-testid="template-type-badge"
>
{templateLabel}
</Badge>
);
})()}
</div> </div>
{/* Title */} {/* Title */}

View File

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

View File

@ -30,7 +30,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
}); });
const fetchRequests = useCallback( const fetchRequests = useCallback(
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => { async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
setLoading(true); setLoading(true);
@ -51,6 +51,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
search: filters?.search, search: filters?.search,
status: filters?.status && filters.status !== 'all' ? filters.status : undefined, status: filters?.status && filters.status !== 'all' ? filters.status : undefined,
priority: filters?.priority, priority: filters?.priority,
templateType: filters?.templateType,
sortBy: filters?.sortBy, sortBy: filters?.sortBy,
sortOrder: filters?.sortOrder sortOrder: filters?.sortOrder
}); });
@ -90,7 +91,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
// Initial fetch removed - component handles initial fetch using Redux stored page // Initial fetch removed - component handles initial fetch using Redux stored page
// This prevents duplicate fetches and allows page persistence // This prevents duplicate fetches and allows page persistence
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => { const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
setRefreshing(true); setRefreshing(true);
fetchRequests(pagination.currentPage, filters); fetchRequests(pagination.currentPage, filters);
}, [fetchRequests, pagination.currentPage]); }, [fetchRequests, pagination.currentPage]);

View File

@ -9,6 +9,7 @@ import {
setSearchTerm as setSearchTermAction, setSearchTerm as setSearchTermAction,
setStatusFilter as setStatusFilterAction, setStatusFilter as setStatusFilterAction,
setPriorityFilter as setPriorityFilterAction, setPriorityFilter as setPriorityFilterAction,
setTemplateTypeFilter as setTemplateTypeFilterAction,
setSortBy as setSortByAction, setSortBy as setSortByAction,
setSortOrder as setSortOrderAction, setSortOrder as setSortOrderAction,
setCurrentPage as setCurrentPageAction, setCurrentPage as setCurrentPageAction,
@ -26,12 +27,13 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
// Get filters from Redux // Get filters from Redux
const { searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests); const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
// Create setters that dispatch Redux actions // Create setters that dispatch Redux actions
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]); const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]); const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]); const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]); const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]);
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]); const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]); const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
@ -41,10 +43,11 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
search: searchTerm, search: searchTerm,
status: statusFilter, status: statusFilter,
priority: priorityFilter, priority: priorityFilter,
templateType: templateTypeFilter !== 'all' ? templateTypeFilter : undefined,
sortBy, sortBy,
sortOrder, sortOrder,
}; };
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder]); }, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder]);
// Debounced filter change handler // Debounced filter change handler
useEffect(() => { useEffect(() => {
@ -71,7 +74,7 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
clearTimeout(debounceTimeoutRef.current); clearTimeout(debounceTimeoutRef.current);
} }
}; };
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]); }, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
dispatch(clearFiltersAction()); dispatch(clearFiltersAction());
@ -80,19 +83,22 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
const activeFiltersCount = [ const activeFiltersCount = [
searchTerm, searchTerm,
priorityFilter !== 'all' ? priorityFilter : null, priorityFilter !== 'all' ? priorityFilter : null,
statusFilter !== 'all' ? statusFilter : null statusFilter !== 'all' ? statusFilter : null,
templateTypeFilter !== 'all' ? templateTypeFilter : null
].filter(Boolean).length; ].filter(Boolean).length;
return { return {
searchTerm, searchTerm,
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
currentPage, currentPage,
setSearchTerm, setSearchTerm,
setPriorityFilter, setPriorityFilter,
setStatusFilter, setStatusFilter,
setTemplateTypeFilter,
setSortBy, setSortBy,
setSortOrder, setSortOrder,
setCurrentPage, setCurrentPage,

View File

@ -4,6 +4,7 @@ export interface ClosedRequestsFiltersState {
searchTerm: string; searchTerm: string;
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority'; sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
currentPage: number; currentPage: number;
@ -13,6 +14,7 @@ const initialState: ClosedRequestsFiltersState = {
searchTerm: '', searchTerm: '',
statusFilter: 'all', statusFilter: 'all',
priorityFilter: 'all', priorityFilter: 'all',
templateTypeFilter: 'all',
sortBy: 'created', sortBy: 'created',
sortOrder: 'desc', sortOrder: 'desc',
currentPage: 1, currentPage: 1,
@ -31,6 +33,9 @@ const closedRequestsSlice = createSlice({
setPriorityFilter: (state, action: PayloadAction<string>) => { setPriorityFilter: (state, action: PayloadAction<string>) => {
state.priorityFilter = action.payload; state.priorityFilter = action.payload;
}, },
setTemplateTypeFilter: (state, action: PayloadAction<string>) => {
state.templateTypeFilter = action.payload;
},
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => { setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => {
state.sortBy = action.payload; state.sortBy = action.payload;
}, },
@ -44,6 +49,7 @@ const closedRequestsSlice = createSlice({
state.searchTerm = ''; state.searchTerm = '';
state.statusFilter = 'all'; state.statusFilter = 'all';
state.priorityFilter = 'all'; state.priorityFilter = 'all';
state.templateTypeFilter = 'all';
state.currentPage = 1; state.currentPage = 1;
}, },
}, },
@ -53,6 +59,7 @@ export const {
setSearchTerm, setSearchTerm,
setStatusFilter, setStatusFilter,
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter,
setSortBy, setSortBy,
setSortOrder, setSortOrder,
setCurrentPage, setCurrentPage,

View File

@ -17,6 +17,7 @@ export interface ClosedRequest {
department?: string; department?: string;
totalLevels?: number; totalLevels?: number;
completedLevels?: number; completedLevels?: number;
templateType?: string; // Template type for badge display
} }
export interface ClosedRequestsProps { export interface ClosedRequestsProps {
@ -27,6 +28,7 @@ export interface ClosedRequestsFilters {
search: string; search: string;
status: string; status: string;
priority: string; priority: string;
templateType?: string;
sortBy: 'created' | 'due' | 'priority'; sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
} }

View File

@ -28,6 +28,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
department: r.department, department: r.department,
totalLevels: r.totalLevels || 0, totalLevels: r.totalLevels || 0,
completedLevels: r.summary?.approvedLevels || 0, completedLevels: r.summary?.approvedLevels || 0,
templateType: r.templateType || r.template_type, // Template type for badge display
}; };
} }

View File

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

View File

@ -132,6 +132,7 @@ export function CreateRequest({
documentErrorModal, documentErrorModal,
openValidationModal, openValidationModal,
closeValidationModal, closeValidationModal,
openPolicyViolationModal,
closePolicyViolationModal, closePolicyViolationModal,
openDocumentErrorModal, openDocumentErrorModal,
closeDocumentErrorModal, closeDocumentErrorModal,
@ -172,6 +173,8 @@ export function CreateRequest({
wizardPrevStep, wizardPrevStep,
user: user!, user: user!,
openValidationModal, openValidationModal,
systemPolicy,
onPolicyViolation: openPolicyViolationModal,
onSubmit, onSubmit,
goToStep, goToStep,
}); });
@ -272,6 +275,7 @@ export function CreateRequest({
<ApprovalWorkflowStep <ApprovalWorkflowStep
formData={formData} formData={formData}
updateFormData={updateFormData} updateFormData={updateFormData}
systemPolicy={systemPolicy}
onValidationError={(error) => onValidationError={(error) =>
openValidationModal( openValidationModal(
error.type as 'error' | 'self-assign' | 'not-found', error.type as 'error' | 'self-assign' | 'not-found',
@ -279,6 +283,7 @@ export function CreateRequest({
error.message error.message
) )
} }
onPolicyViolation={openPolicyViolationModal}
/> />
); );
case 4: case 4:

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