Compare commits

...

12 Commits

72 changed files with 11355 additions and 1480 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

@ -9,7 +9,7 @@ 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 { 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';
@ -24,15 +24,9 @@ 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
export const REQUEST_DATABASE: any = {
...CUSTOM_REQUEST_DATABASE,
...CLAIM_MANAGEMENT_DATABASE
};
interface AppProps { interface AppProps {
onLogout?: () => void; onLogout?: () => void;
@ -61,6 +55,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(() => {
@ -101,17 +109,19 @@ function AppRoutes({ onLogout }: AppProps) {
} }
}; };
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => { const handleViewRequest = async (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'; const { navigateToRequest } = await import('@/utils/requestNavigation');
if (isDraft) { navigateToRequest({
navigate(`/edit-request/${requestId}`); requestId,
} else { requestTitle,
navigate(`/request/${requestId}`); status,
} request,
navigate,
});
}; };
const handleBack = () => { const handleBack = () => {
@ -265,7 +275,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,
selectedManagerEmail: selectedManagerEmail || undefined,
};
// 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')}`;
@ -457,6 +561,7 @@ 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 (
@ -674,6 +779,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

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

@ -27,8 +27,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 +51,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 +121,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 +134,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 +150,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

@ -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 +0,0 @@
export { ClaimManagementWizard } from './ClaimManagementWizard';

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';

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

@ -0,0 +1,27 @@
/**
* 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';
// Re-export types
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';

View File

@ -0,0 +1,644 @@
/**
* 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';
// 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 { 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,
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
// 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' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
// 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}
/>
</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}
/>
</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}
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}
/>
</>
);
}
/**
* Custom RequestDetail Component (Exported)
*/
export function CustomRequestDetail(props: RequestDetailProps) {
return (
<RequestDetailErrorBoundary>
<CustomRequestDetailInner {...props} />
</RequestDetailErrorBoundary>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -25,7 +25,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getAllDealers, getDealerInfo, formatDealerAddress } from '@/utils/dealerDatabase'; import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
interface ClaimManagementWizardProps { interface ClaimManagementWizardProps {
onBack?: () => void; onBack?: () => void;
@ -41,9 +41,6 @@ const CLAIM_TYPES = [
'Service Campaign' 'Service Campaign'
]; ];
// Fetch dealers from database
const DEALERS = getAllDealers();
const STEP_NAMES = [ const STEP_NAMES = [
'Claim Details', 'Claim Details',
'Review & Submit' 'Review & Submit'
@ -51,6 +48,8 @@ const STEP_NAMES = [
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) { export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [dealers, setDealers] = useState<DealerInfo[]>([]);
const [loadingDealers, setLoadingDealers] = useState(true);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
activityName: '', activityName: '',
@ -70,6 +69,23 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const totalSteps = STEP_NAMES.length; const totalSteps = STEP_NAMES.length;
// Fetch dealers from API on component mount
useEffect(() => {
const fetchDealers = async () => {
setLoadingDealers(true);
try {
const fetchedDealers = await fetchDealersFromAPI();
setDealers(fetchedDealers);
} catch (error) {
toast.error('Failed to load dealer list.');
console.error('Error fetching dealers:', error);
} finally {
setLoadingDealers(false);
}
};
fetchDealers();
}, []);
const updateFormData = (field: string, value: any) => { const updateFormData = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
}; };
@ -103,14 +119,26 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
} }
}; };
const handleDealerChange = (dealerCode: string) => { const handleDealerChange = async (dealerCode: string) => {
const dealer = getDealerInfo(dealerCode); const selectedDealer = dealers.find(d => d.dealerCode === dealerCode);
if (dealer) { if (selectedDealer) {
updateFormData('dealerCode', dealer.code); updateFormData('dealerCode', dealerCode);
updateFormData('dealerName', dealer.name); updateFormData('dealerName', selectedDealer.dealerName);
updateFormData('dealerEmail', dealer.email); updateFormData('dealerEmail', selectedDealer.email || '');
updateFormData('dealerPhone', dealer.phone); updateFormData('dealerPhone', selectedDealer.phone || '');
updateFormData('dealerAddress', formatDealerAddress(dealer)); updateFormData('dealerAddress', ''); // Address not available in API response
// Try to fetch full dealer info from API
try {
const fullDealerInfo = await getDealerByCode(dealerCode);
if (fullDealerInfo) {
updateFormData('dealerEmail', fullDealerInfo.email || selectedDealer.email || '');
updateFormData('dealerPhone', fullDealerInfo.phone || selectedDealer.phone || '');
}
} catch (error) {
// Ignore error, use basic info from list
console.debug('Could not fetch full dealer info:', error);
}
} }
}; };
@ -174,10 +202,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
] ]
}; };
toast.success('Claim Request Created', { // Don't show toast here - let the parent component handle success/error after API call
description: 'Your claim management request has been submitted successfully.'
});
if (onSubmit) { if (onSubmit) {
onSubmit(claimData); onSubmit(claimData);
} }
@ -220,7 +245,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div> <div>
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label> <Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
<Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}> <Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}>
<SelectTrigger className="mt-2 h-12"> <SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="activityType">
<SelectValue placeholder="Select activity type" /> <SelectValue placeholder="Select activity type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -234,10 +259,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{/* Dealer Selection */} {/* Dealer Selection */}
<div> <div>
<Label htmlFor="dealer" className="text-base font-semibold">Dealer Code / Dealer Name *</Label> <Label className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
<Select value={formData.dealerCode} onValueChange={handleDealerChange}> <Select value={formData.dealerCode} onValueChange={handleDealerChange} disabled={loadingDealers}>
<SelectTrigger className="mt-2 h-12"> <SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="dealer-select">
<SelectValue placeholder="Select dealer"> <SelectValue placeholder={loadingDealers ? "Loading dealers..." : "Select dealer"}>
{formData.dealerCode && ( {formData.dealerCode && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-sm">{formData.dealerCode}</span> <span className="font-mono text-sm">{formData.dealerCode}</span>
@ -248,15 +273,19 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{DEALERS.map((dealer) => ( {dealers.length === 0 && !loadingDealers ? (
<SelectItem key={dealer.code} value={dealer.code}> <div className="p-2 text-sm text-gray-500">No dealers available</div>
<div className="flex items-center gap-2"> ) : (
<span className="font-mono text-sm font-semibold">{dealer.code}</span> dealers.map((dealer) => (
<span className="text-gray-400"></span> <SelectItem key={dealer.userId} value={dealer.dealerCode}>
<span>{dealer.name}</span> <div className="flex items-center gap-2">
</div> <span className="font-mono text-sm font-semibold">{dealer.dealerCode}</span>
</SelectItem> <span className="text-gray-400"></span>
))} <span>{dealer.dealerName}</span>
</div>
</SelectItem>
))
)}
</SelectContent> </SelectContent>
</Select> </Select>
{formData.dealerCode && ( {formData.dealerCode && (

View File

@ -0,0 +1,393 @@
/**
* 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;
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 && existingBlockedAmount > 0) {
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
// 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';
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: Number(existingBlockedAmount) || 0,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
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',
});
setIoNumber(existingIONumber);
// 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 available balance
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
*/
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 blockAmount = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmount) || blockAmount <= 0) {
toast.error('Please enter a valid amount to block');
return;
}
if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget');
return;
}
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
await updateIODetails(requestId, {
ioNumber: ioNumber.trim(),
ioAvailableBalance: fetchedAmount,
ioBlockedAmount: blockAmount,
ioRemainingBalance: fetchedAmount - blockAmount,
});
// 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 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: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount),
availableBalance: fetchedAmount, // Available amount before block
remainingBalance: Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount)),
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>
{/* 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>
</div>
{/* Block Button */}
<Button
onClick={handleBlockBudget}
disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
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,311 @@
/**
* 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;
}
export function ClaimManagementOverviewTab({
request: _request,
apiRequest,
currentUserId,
isInitiator: _isInitiator,
onEditClaimAmount: _onEditClaimAmount,
className = '',
needsClosure = false,
conclusionRemark = '',
setConclusionRemark,
conclusionLoading = false,
conclusionSubmitting = false,
aiGenerated = false,
handleGenerateConclusion,
handleFinalizeConclusion,
}: 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>
);
}
// Debug: Log mapped data for troubleshooting
console.debug('[ClaimManagementOverviewTab] Mapped claim data:', {
activityInfo: claimRequest.activityInfo,
dealerInfo: claimRequest.dealerInfo,
hasProposalDetails: !!claimRequest.proposalDetails,
closedExpenses: claimRequest.activityInfo?.closedExpenses,
closedExpensesBreakdown: claimRequest.activityInfo?.closedExpensesBreakdown,
hasDealerCode: !!claimRequest.dealerInfo?.dealerCode,
hasDealerName: !!claimRequest.dealerInfo?.dealerName,
});
// Determine user's role
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
// Get visibility settings based on role
const visibility = getRoleBasedVisibility(userRole);
console.debug('[ClaimManagementOverviewTab] User role and visibility:', {
userRole,
visibility,
currentUserId,
showDealerInfo: visibility.showDealerInfo,
dealerInfoPresent: !!(claimRequest.dealerInfo?.dealerCode || claimRequest.dealerInfo?.dealerName),
});
// 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,
};
// Debug: Log closure props to help troubleshoot
console.debug('[ClaimManagementOverviewTab] Closure setup check:', {
needsClosure,
requestStatus: apiRequest?.status,
requestStatusLower: (apiRequest?.status || '').toLowerCase(),
hasConclusionRemark: !!conclusionRemark,
conclusionRemarkLength: conclusionRemark?.length || 0,
conclusionLoading,
conclusionSubmitting,
aiGenerated,
hasHandleGenerate: !!handleGenerateConclusion,
hasHandleFinalize: !!handleFinalizeConclusion,
hasSetConclusion: !!setConclusionRemark,
});
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 && (
<Button
variant="outline"
size="sm"
onClick={handleGenerateConclusion}
disabled={conclusionLoading}
className="gap-2 shrink-0"
data-testid="generate-ai-conclusion-button"
>
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
)}
</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,202 @@
/**
* 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';
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>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{activityInfo.description}
</p>
</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,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';
};
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 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,376 @@
/**
* 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 } 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,
} from 'lucide-react';
import { toast } from 'sonner';
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 DMSPushModalProps {
isOpen: boolean;
onClose: () => void;
onPush: (comments: string) => Promise<void>;
completionDetails?: CompletionDetails | null;
ioDetails?: IODetails | null;
requestTitle?: string;
requestNumber?: string;
}
export function DMSPushModal({
isOpen,
onClose,
onPush,
completionDetails,
ioDetails,
requestTitle,
requestNumber,
}: DMSPushModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = 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 })}`;
};
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="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-indigo-100">
<Activity className="w-6 h-6 text-indigo-600" />
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-xl">
Push to DMS - Verification
</DialogTitle>
<DialogDescription className="text-sm mt-1">
Review completion details and expenses before pushing to DMS for e-invoice generation
</DialogDescription>
</div>
</div>
{/* Request Info Card */}
<div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono">Step 6</Badge>
</div>
{requestNumber && (
<div>
<span className="font-medium text-gray-900">Request Number:</span>
<p className="text-gray-700 mt-1 font-mono">{requestNumber}</p>
</div>
)}
<div>
<span className="font-medium text-gray-900">Title:</span>
<p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">Action:</span>
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200">
<Activity className="w-3 h-3 mr-1" />
PUSH TO DMS
</Badge>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Completion Details Card */}
{completionDetails && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<CheckCircle2 className="w-5 h-5 text-green-600" />
Completion Details
</CardTitle>
<CardDescription>
Review activity completion information
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{completionDetails.activityCompletionDate && (
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm text-gray-600">Activity Completion Date:</span>
<span className="text-sm font-semibold text-gray-900">
{formatDate(completionDetails.activityCompletionDate)}
</span>
</div>
)}
{completionDetails.numberOfParticipants !== undefined && (
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm text-gray-600">Number of Participants:</span>
<span className="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-sm text-gray-900">
{completionDetails.completionDescription}
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Expense Breakdown Card */}
{completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<DollarSign className="w-5 h-5 text-blue-600" />
Expense Breakdown
</CardTitle>
<CardDescription>
Review closed expenses before pushing to DMS
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{completionDetails.closedExpenses.map((expense, index) => (
<div
key={index}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border"
>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">
{expense.description || `Expense ${index + 1}`}
</p>
</div>
<div className="ml-4">
<p className="text-sm font-semibold text-gray-900">
{formatCurrency(typeof expense === 'object' ? (expense.amount || 0) : 0)}
</p>
</div>
</div>
))}
<div className="flex items-center justify-between py-3 px-3 bg-blue-50 rounded border-2 border-blue-200 mt-3">
<span className="text-sm font-semibold text-gray-900">Total Closed Expenses:</span>
<span className="text-lg font-bold text-blue-700">
{formatCurrency(totalClosedExpenses)}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* IO Details Card */}
{ioDetails && ioDetails.ioNumber && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Receipt className="w-5 h-5 text-purple-600" />
IO Details
</CardTitle>
<CardDescription>
Internal Order information for budget reference
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm text-gray-600">IO Number:</span>
<span className="text-sm font-semibold text-gray-900 font-mono">
{ioDetails.ioNumber}
</span>
</div>
{ioDetails.blockedAmount !== undefined && ioDetails.blockedAmount > 0 && (
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm text-gray-600">Blocked Amount:</span>
<span className="text-sm font-bold text-green-700">
{formatCurrency(ioDetails.blockedAmount)}
</span>
</div>
)}
{ioDetails.remainingBalance !== undefined && ioDetails.remainingBalance !== null && (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-gray-600">Remaining Balance:</span>
<span className="text-sm font-semibold text-gray-900">
{formatCurrency(ioDetails.remainingBalance)}
</span>
</div>
)}
</CardContent>
</Card>
)}
{/* Verification Warning */}
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-start gap-2">
<TriangleAlert className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div>
<p className="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 the workflow will proceed to Step 7.
</p>
</div>
</div>
</div>
{/* Comments & Remarks */}
<div className="space-y-1.5">
<Label htmlFor="comment" className="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>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<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>
</Dialog>
);
}

View File

@ -0,0 +1,733 @@
/**
* DealerCompletionDocumentsModal Component
* Modal for Step 5: Activity Completion Documents
* Allows dealers to upload completion documents, photos, expenses, and provide completion details
*/
import { useState, useRef, useMemo } 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 { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
interface ExpenseItem {
id: string;
description: string;
amount: number;
}
interface DealerCompletionDocumentsModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: {
activityCompletionDate: string;
numberOfParticipants?: number;
closedExpenses: ExpenseItem[];
totalClosedExpenses: number;
completionDocuments: File[];
activityPhotos: File[];
invoicesReceipts?: File[];
attendanceSheet?: File;
completionDescription: string;
}) => Promise<void>;
dealerName?: string;
activityName?: string;
requestId?: string;
}
export function DealerCompletionDocumentsModal({
isOpen,
onClose,
onSubmit,
dealerName = 'Jaipur Royal Enfield',
activityName = 'Activity',
requestId: _requestId,
}: DealerCompletionDocumentsModalProps) {
const [activityCompletionDate, setActivityCompletionDate] = useState('');
const [numberOfParticipants, setNumberOfParticipants] = useState('');
const [expenseItems, setExpenseItems] = useState<ExpenseItem[]>([]);
const [completionDocuments, setCompletionDocuments] = useState<File[]>([]);
const [activityPhotos, setActivityPhotos] = useState<File[]>([]);
const [invoicesReceipts, setInvoicesReceipts] = useState<File[]>([]);
const [attendanceSheet, setAttendanceSheet] = useState<File | null>(null);
const [completionDescription, setCompletionDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const completionDocsInputRef = useRef<HTMLInputElement>(null);
const photosInputRef = useRef<HTMLInputElement>(null);
const invoicesInputRef = useRef<HTMLInputElement>(null);
const attendanceInputRef = useRef<HTMLInputElement>(null);
// Calculate total closed expenses
const totalClosedExpenses = useMemo(() => {
return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0);
}, [expenseItems]);
// Check if all required fields are filled
const isFormValid = useMemo(() => {
const hasCompletionDate = activityCompletionDate !== '';
const hasDocuments = completionDocuments.length > 0;
const hasPhotos = activityPhotos.length > 0;
const hasDescription = completionDescription.trim().length > 0;
return hasCompletionDate && hasDocuments && hasPhotos && hasDescription;
}, [activityCompletionDate, completionDocuments, activityPhotos, completionDescription]);
// Get today's date in YYYY-MM-DD format for max date
const maxDate = new Date().toISOString().split('T')[0];
const handleAddExpense = () => {
setExpenseItems([
...expenseItems,
{ id: Date.now().toString(), description: '', amount: 0 },
]);
};
const handleExpenseChange = (id: string, field: 'description' | 'amount', value: string | number) => {
setExpenseItems(
expenseItems.map((item) =>
item.id === id ? { ...item, [field]: value } : item
)
);
};
const handleRemoveExpense = (id: string) => {
setExpenseItems(expenseItems.filter((item) => item.id !== id));
};
const handleCompletionDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
// Validate file types
const allowedTypes = ['.pdf', '.doc', '.docx', '.zip', '.rar'];
const invalidFiles = files.filter(
(file) => !allowedTypes.some((ext) => file.name.toLowerCase().endsWith(ext))
);
if (invalidFiles.length > 0) {
toast.error('Please upload PDF, DOC, DOCX, ZIP, or RAR files only');
return;
}
setCompletionDocuments([...completionDocuments, ...files]);
}
};
const handleRemoveCompletionDoc = (index: number) => {
setCompletionDocuments(completionDocuments.filter((_, i) => i !== index));
};
const handlePhotosChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
// Validate image files
const invalidFiles = files.filter(
(file) => !file.type.startsWith('image/')
);
if (invalidFiles.length > 0) {
toast.error('Please upload image files only (JPG, PNG, etc.)');
return;
}
setActivityPhotos([...activityPhotos, ...files]);
}
};
const handleRemovePhoto = (index: number) => {
setActivityPhotos(activityPhotos.filter((_, i) => i !== index));
};
const handleInvoicesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
// Validate file types
const allowedTypes = ['.pdf', '.jpg', '.jpeg', '.png'];
const invalidFiles = files.filter(
(file) => !allowedTypes.some((ext) => file.name.toLowerCase().endsWith(ext))
);
if (invalidFiles.length > 0) {
toast.error('Please upload PDF, JPG, or PNG files only');
return;
}
setInvoicesReceipts([...invoicesReceipts, ...files]);
}
};
const handleRemoveInvoice = (index: number) => {
setInvoicesReceipts(invoicesReceipts.filter((_, i) => i !== index));
};
const handleAttendanceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file types
const allowedTypes = ['.pdf', '.xlsx', '.xls', '.csv'];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!allowedTypes.includes(fileExtension)) {
toast.error('Please upload PDF, Excel, or CSV files only');
return;
}
setAttendanceSheet(file);
}
};
const handleSubmit = async () => {
if (!isFormValid) {
toast.error('Please fill all required fields');
return;
}
// Filter valid expense items
const validExpenses = expenseItems.filter(
(item) => item.description.trim() !== '' && item.amount > 0
);
try {
setSubmitting(true);
await onSubmit({
activityCompletionDate,
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
closedExpenses: validExpenses,
totalClosedExpenses,
completionDocuments,
activityPhotos,
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
attendanceSheet: attendanceSheet || undefined,
completionDescription,
});
handleReset();
onClose();
} catch (error) {
console.error('Failed to submit completion documents:', error);
toast.error('Failed to submit completion documents. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setActivityCompletionDate('');
setNumberOfParticipants('');
setExpenseItems([]);
setCompletionDocuments([]);
setActivityPhotos([]);
setInvoicesReceipts([]);
setAttendanceSheet(null);
setCompletionDescription('');
if (completionDocsInputRef.current) completionDocsInputRef.current.value = '';
if (photosInputRef.current) photosInputRef.current.value = '';
if (invoicesInputRef.current) invoicesInputRef.current.value = '';
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg max-w-4xl max-h-[90vh] overflow-y-auto overflow-x-hidden">
<DialogHeader>
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
<Upload className="w-6 h-6 text-[--re-green]" />
Activity Completion Documents
</DialogTitle>
<DialogDescription className="text-base">
Step 5: Upload completion proof and final documents
</DialogDescription>
<div className="space-y-1 mt-2 text-sm text-gray-600">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
<div className="mt-2">
Please upload completion documents, photos, and provide details about the completed activity.
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Activity Completion Date */}
<div className="space-y-2">
<Label className="text-base font-semibold flex items-center gap-2" htmlFor="completionDate">
<Calendar className="w-4 h-4" />
Activity Completion Date *
</Label>
<Input
type="date"
id="completionDate"
max={maxDate}
value={activityCompletionDate}
onChange={(e) => setActivityCompletionDate(e.target.value)}
/>
</div>
{/* Closed Expenses Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Closed Expenses</h3>
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
</div>
<Button
type="button"
onClick={handleAddExpense}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
size="sm"
>
<Plus className="w-4 h-4 mr-1" />
Add Expense
</Button>
</div>
<div className="space-y-3">
{expenseItems.map((item) => (
<div key={item.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input
placeholder="Item name (e.g., Venue rental, Refreshments, Printing)"
value={item.description}
onChange={(e) =>
handleExpenseChange(item.id, 'description', e.target.value)
}
/>
</div>
<div className="w-40">
<Input
type="number"
placeholder="Amount"
min="0"
step="0.01"
value={item.amount || ''}
onChange={(e) =>
handleExpenseChange(item.id, 'amount', parseFloat(e.target.value) || 0)
}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="mt-0.5 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveExpense(item.id)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{expenseItems.length === 0 && (
<p className="text-sm text-gray-500 italic">
No expenses added. Click "Add Expense" to add expense items.
</p>
)}
{expenseItems.length > 0 && totalClosedExpenses > 0 && (
<div className="pt-2 border-t">
<div className="flex justify-between items-center">
<span className="font-semibold">Total Closed Expenses:</span>
<span className="font-semibold text-lg">
{totalClosedExpenses.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
</div>
{/* Completion Evidence Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Completion Evidence</h3>
<Badge className="bg-destructive text-white text-xs">Required</Badge>
</div>
{/* Completion Documents */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<FileText className="w-4 h-4" />
Completion Documents *
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
completionDocuments.length > 0
? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={completionDocsInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.zip,.rar"
className="hidden"
id="completionDocs"
onChange={handleCompletionDocsChange}
/>
<label
htmlFor="completionDocs"
className="cursor-pointer flex flex-col items-center gap-2"
>
{completionDocuments.length > 0 ? (
<>
<CheckCircle2 className="w-8 h-8 text-green-600" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-semibold text-green-700">
{completionDocuments.length} document{completionDocuments.length !== 1 ? 's' : ''} selected
</span>
<span className="text-xs text-green-600">
Click to add more documents
</span>
</div>
</>
) : (
<>
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
Click to upload documents (PDF, DOC, ZIP - multiple files allowed)
</span>
</>
)}
</label>
</div>
{completionDocuments.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-gray-600 mb-1">
Selected Documents ({completionDocuments.length}):
</p>
{completionDocuments.map((file, index) => (
<div
key={index}
className="flex items-start justify-between bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-3 rounded-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-green-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
onClick={() => handleRemoveCompletionDoc(index)}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Activity Photos */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<Image className="w-4 h-4" />
Activity Photos *
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload photos from the completed activity (event photos, installations, etc.)
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
activityPhotos.length > 0
? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={photosInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
id="completionPhotos"
onChange={handlePhotosChange}
/>
<label
htmlFor="completionPhotos"
className="cursor-pointer flex flex-col items-center gap-2"
>
{activityPhotos.length > 0 ? (
<>
<CheckCircle2 className="w-8 h-8 text-green-600" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-semibold text-green-700">
{activityPhotos.length} photo{activityPhotos.length !== 1 ? 's' : ''} selected
</span>
<span className="text-xs text-green-600">
Click to add more photos
</span>
</div>
</>
) : (
<>
<Image className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
Click to upload photos (JPG, PNG - multiple files allowed)
</span>
</>
)}
</label>
</div>
{activityPhotos.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-gray-600 mb-1">
Selected Photos ({activityPhotos.length}):
</p>
{activityPhotos.map((file, index) => (
<div
key={index}
className="flex items-start justify-between bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-3 rounded-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">
<Image className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
onClick={() => handleRemovePhoto(index)}
title="Remove photo"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Supporting Documents Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Supporting Documents</h3>
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
</div>
{/* Invoices/Receipts */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<Receipt className="w-4 h-4" />
Invoices / Receipts
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload invoices and receipts for expenses incurred
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
invoicesReceipts.length > 0
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={invoicesInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
id="invoiceReceipts"
onChange={handleInvoicesChange}
/>
<label
htmlFor="invoiceReceipts"
className="cursor-pointer flex flex-col items-center gap-2"
>
{invoicesReceipts.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">
{invoicesReceipts.length} document{invoicesReceipts.length !== 1 ? 's' : ''} selected
</span>
<span className="text-xs text-blue-600">
Click to add more documents
</span>
</div>
</>
) : (
<>
<Receipt className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
Click to upload invoices/receipts (PDF, JPG, PNG)
</span>
</>
)}
</label>
</div>
{invoicesReceipts.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-gray-600 mb-1">
Selected Documents ({invoicesReceipts.length}):
</p>
{invoicesReceipts.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-3 rounded-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">
<Receipt 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>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
onClick={() => handleRemoveInvoice(index)}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Attendance Sheet */}
<div>
<Label className="text-base font-semibold">
Attendance Sheet / Participant List
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload attendance records or participant lists (if applicable)
</p>
<div
className={`border-2 border-dashed rounded-lg p-4 transition-all duration-200 ${
attendanceSheet
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white'
}`}
>
<input
ref={attendanceInputRef}
type="file"
accept=".pdf,.xlsx,.xls,.csv"
className="hidden"
id="attendanceDoc"
onChange={handleAttendanceChange}
/>
<label
htmlFor="attendanceDoc"
className="cursor-pointer flex flex-col items-center gap-2"
>
{attendanceSheet ? (
<>
<CheckCircle2 className="w-8 h-8 text-blue-600" />
<div className="flex flex-col items-center gap-1 w-full max-w-full px-2">
<span className="text-sm font-semibold text-blue-700 break-words text-center w-full max-w-full">
{attendanceSheet.name}
</span>
<span className="text-xs text-blue-600">
Document selected
</span>
</div>
</>
) : (
<>
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
Click to upload attendance sheet (Excel, PDF, CSV)
</span>
</>
)}
</label>
</div>
{attendanceSheet && (
<div className="mt-3">
<p className="text-xs font-medium text-gray-600 mb-2">
Selected Document:
</p>
<div className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-3 rounded-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">{attendanceSheet.name}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
onClick={() => {
setAttendanceSheet(null);
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
}}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
</div>
{/* Completion Description */}
<div className="space-y-2">
<Label className="text-base font-semibold flex items-center gap-2" htmlFor="completionDescription">
Brief Description of Completion *
</Label>
<Textarea
id="completionDescription"
placeholder="Provide a brief description of the completed activity, including key highlights, outcomes, challenges faced, and any relevant observations..."
value={completionDescription}
onChange={(e) => setCompletionDescription(e.target.value)}
className="min-h-[120px]"
/>
<p className="text-xs text-gray-500">{completionDescription.length} characters</p>
</div>
{/* Warning Message */}
{!isFormValid && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<CircleAlert className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-semibold mb-1">Missing Required Information</p>
<p>
Please ensure completion date, at least one document/photo, and description are provided before submitting.
</p>
</div>
</div>
)}
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="border-2"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || !isFormValid}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white disabled:bg-gray-300 disabled:text-gray-500"
>
{submitting ? 'Submitting...' : 'Submit Documents'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,536 @@
/**
* 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 } 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 { Upload, Plus, X, Calendar, DollarSign, CircleAlert, CheckCircle2, FileText } from 'lucide-react';
import { toast } from 'sonner';
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;
}
export function DealerProposalSubmissionModal({
isOpen,
onClose,
onSubmit,
dealerName = 'Jaipur Royal Enfield',
activityName = 'Activity',
requestId: _requestId,
}: 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 proposalDocInputRef = useRef<HTMLInputElement>(null);
const otherDocsInputRef = useRef<HTMLInputElement>(null);
// 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) {
// Validate file type
const allowedTypes = ['.pdf', '.doc', '.docx'];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!allowedTypes.includes(fileExtension)) {
toast.error('Please upload a PDF, DOC, or DOCX file');
return;
}
setProposalDocument(file);
}
};
const handleOtherDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
setOtherDocuments(prev => [...prev, ...files]);
};
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 = () => {
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="max-w-4xl max-h-[90vh] overflow-y-auto overflow-x-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-2xl">
<Upload className="w-6 h-6 text-[--re-green]" />
Dealer Proposal Submission
</DialogTitle>
<DialogDescription className="text-base">
Step 1: Upload proposal and planning details
</DialogDescription>
<div className="space-y-1 mt-2 text-sm text-gray-600">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
<div className="mt-2">
Please upload proposal document, provide cost breakdown, timeline, and detailed comments.
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Proposal Document Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Proposal Document</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<div>
<Label className="text-base font-semibold flex items-center gap-2">
Proposal Document *
</Label>
<p className="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-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"
className="hidden"
id="proposalDoc"
onChange={handleProposalDocChange}
/>
<label
htmlFor="proposalDoc"
className="cursor-pointer flex flex-col items-center gap-2"
>
{proposalDocument ? (
<>
<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">
Document selected
</span>
</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>
{/* Cost Breakup Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Cost Breakup</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<Button
type="button"
onClick={handleAddCostItem}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
size="sm"
>
<Plus className="w-4 h-4 mr-1" />
Add Item
</Button>
</div>
<div className="space-y-3">
{costItems.map((item) => (
<div key={item.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input
placeholder="Item description (e.g., Banner printing, Event setup)"
value={item.description}
onChange={(e) =>
handleCostItemChange(item.id, 'description', e.target.value)
}
/>
</div>
<div className="w-40">
<Input
type="number"
placeholder="Amount"
min="0"
step="0.01"
value={item.amount || ''}
onChange={(e) =>
handleCostItemChange(item.id, 'amount', e.target.value)
}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="mt-0.5 hover:bg-red-100 hover:text-red-700"
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-4 bg-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-gray-700" />
<span className="font-semibold text-gray-900">Estimated Budget</span>
</div>
<div className="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-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Timeline for Closure</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<div className="space-y-3">
<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>
<Label className="text-sm font-medium mb-2 block">
Expected Completion Date
</Label>
<Input
type="date"
min={minDate}
value={expectedCompletionDate}
onChange={(e) => setExpectedCompletionDate(e.target.value)}
/>
</div>
) : (
<div>
<Label className="text-sm font-medium 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)}
/>
</div>
)}
</div>
</div>
{/* Other Supporting Documents Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Other Supporting Documents</h3>
<Badge variant="secondary" className="text-xs">Optional</Badge>
</div>
<div>
<Label className="flex items-center gap-2 text-base font-semibold">
Additional Documents
</Label>
<p className="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-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
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">
Click to upload additional documents (multiple files allowed)
</span>
</>
)}
</label>
</div>
{otherDocuments.length > 0 && (
<div className="mt-3 space-y-2">
<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-3 rounded-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>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
onClick={() => handleRemoveOtherDoc(index)}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Dealer Comments Section */}
<div className="space-y-2">
<Label htmlFor="dealerComments" className="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-[120px]"
/>
<p className="text-xs text-gray-500">{dealerComments.length} characters</p>
</div>
{/* Warning Message */}
{!isFormValid && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<CircleAlert className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="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>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<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>
</Dialog>
);
}

View File

@ -0,0 +1,359 @@
/**
* 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';
interface DeptLeadIOApprovalModalProps {
isOpen: boolean;
onClose: () => void;
onApprove: (data: {
ioNumber: string;
ioRemark: 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 [ioRemark, setIoRemark] = useState('');
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) {
// Reset form when modal opens
setIoRemark('');
setComments('');
setActionType('approve');
}
}, [isOpen]);
const ioRemarkChars = ioRemark.length;
const commentsChars = comments.length;
const maxIoRemarkChars = 300;
const maxCommentsChars = 500;
// Validate form
const isFormValid = useMemo(() => {
if (actionType === 'reject') {
return comments.trim().length > 0;
}
// For approve, need IO number (from table), IO remark, and comments
return (
ioNumber.trim().length > 0 && // IO number must exist from IO table
ioRemark.trim().length > 0 &&
comments.trim().length > 0
);
}, [actionType, ioNumber, ioRemark, 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 (!ioRemark.trim()) {
toast.error('Please enter IO remark');
return;
}
}
if (!comments.trim()) {
toast.error('Please provide comments');
return;
}
return;
}
try {
setSubmitting(true);
if (actionType === 'approve') {
await onApprove({
ioNumber: ioNumber.trim(),
ioRemark: ioRemark.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');
setIoRemark('');
setComments('');
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-green-100">
<CircleCheckBig className="w-6 h-6 text-green-600" />
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-xl">
Approve and Organise IO
</DialogTitle>
<DialogDescription className="text-sm mt-1">
Review IO details and provide your approval comments
</DialogDescription>
</div>
</div>
{/* Request Info Card */}
<div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono">Step 3</Badge>
</div>
<div>
<span className="font-medium text-gray-900">Title:</span>
<p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">Action:</span>
<Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" />
APPROVE
</Badge>
</div>
</div>
</DialogHeader>
<div className="space-y-3">
{/* Action Toggle Buttons */}
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<Button
type="button"
onClick={() => setActionType('approve')}
className={`flex-1 ${
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 ${
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>
{/* IO Organisation Details - Only shown when approving */}
{actionType === 'approve' && (
<div className="p-3 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 text-blue-600" />
<h4 className="font-semibold 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-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 cursor-not-allowed"
/>
{!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-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-sm font-bold text-blue-700 mt-1">
{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
{/* IO Remark - Only editable field */}
<div className="space-y-1">
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Remark <span className="text-red-500">*</span>
</Label>
<Textarea
id="ioRemark"
placeholder="Enter remarks about IO organization"
value={ioRemark}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxIoRemarkChars) {
setIoRemark(value);
}
}}
rows={2}
className="bg-white text-sm min-h-[60px] resize-none"
/>
<div className="flex justify-end text-xs text-gray-600">
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
</div>
</div>
</div>
)}
{/* Comments & Remarks */}
<div className="space-y-1.5">
<Label htmlFor="comment" className="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-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>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || submitting}
className={`${
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]">
<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 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,442 @@
/**
* InitiatorProposalApprovalModal Component
* Modal for Step 2: Requestor Evaluation & Confirmation
* Allows initiator to review dealer's proposal and approve/reject
*/
import { useState, useMemo } 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,
DollarSign,
Calendar,
MessageSquare,
Download,
Eye,
} from 'lucide-react';
import { toast } from 'sonner';
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>;
proposalData: ProposalData | null;
dealerName?: string;
activityName?: string;
requestId?: string;
}
export function InitiatorProposalApprovalModal({
isOpen,
onClose,
onApprove,
onReject,
proposalData,
dealerName = 'Dealer',
activityName = 'Activity',
requestId: _requestId, // Prefix with _ to indicate intentionally unused
}: InitiatorProposalApprovalModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 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;
}
};
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 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="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0 border-b">
<DialogTitle className="flex items-center gap-2 text-2xl">
<CheckCircle className="w-6 h-6 text-green-600" />
Requestor Evaluation & Confirmation
</DialogTitle>
<DialogDescription className="text-base">
Step 2: Review dealer proposal and make a decision
</DialogDescription>
<div className="space-y-1 mt-2 text-sm text-gray-600">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
<div className="mt-2 text-amber-600 font-medium">
Decision: <strong>Confirms?</strong> (YES Continue to Dept Lead / NO Request is cancelled)
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-4 px-6 overflow-y-auto flex-1">
{/* Proposal Document Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
Proposal Document
</h3>
</div>
{proposalData?.proposalDocument ? (
<div className="border rounded-lg p-4 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<p className="font-medium text-gray-900">{proposalData.proposalDocument.name}</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500">
Submitted on {formatDate(proposalData.submittedAt)}
</p>
)}
</div>
</div>
<div className="flex gap-2">
{proposalData.proposalDocument.url && (
<>
<Button
variant="outline"
size="sm"
onClick={() => window.open(proposalData.proposalDocument?.url, '_blank')}
>
<Eye className="w-4 h-4 mr-1" />
View
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = proposalData.proposalDocument?.url || '';
link.download = proposalData.proposalDocument?.name || '';
link.click();
}}
>
<Download className="w-4 h-4 mr-1" />
Download
</Button>
</>
)}
</div>
</div>
) : (
<p className="text-sm text-gray-500 italic">No proposal document available</p>
)}
</div>
{/* Cost Breakup Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<DollarSign className="w-5 h-5 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">
<div className="bg-gray-50 px-4 py-2 border-b">
<div className="grid grid-cols-2 gap-4 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-4 py-3 grid grid-cols-2 gap-4">
<div className="text-sm text-gray-700">{item?.description || 'N/A'}</div>
<div className="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-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[--re-green]" />
<span className="font-semibold text-gray-700">Total Estimated Budget</span>
</div>
<div className="text-2xl font-bold text-[--re-green]">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</>
) : (
<p className="text-sm text-gray-500 italic">No cost breakdown available</p>
);
})()}
</div>
{/* Timeline Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<Calendar className="w-5 h-5 text-purple-600" />
Expected Completion Date
</h3>
</div>
<div className="border rounded-lg p-4 bg-gray-50">
<p className="text-lg font-semibold text-gray-900">
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
</p>
</div>
</div>
{/* Other Supporting Documents */}
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<FileText className="w-5 h-5 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">
{proposalData.otherDocuments.map((doc, index) => (
<div
key={index}
className="border rounded-lg p-3 bg-gray-50 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-gray-600" />
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
</div>
{doc.url && (
<Button
variant="ghost"
size="sm"
onClick={() => window.open(doc.url, '_blank')}
>
<Download className="w-4 h-4" />
</Button>
)}
</div>
))}
</div>
</div>
)}
{/* Dealer Comments */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
Dealer Comments
</h3>
</div>
<div className="border rounded-lg p-4 bg-gray-50">
<p className="text-sm text-gray-700 whitespace-pre-wrap">
{proposalData?.dealerComments || 'No comments provided'}
</p>
</div>
</div>
{/* Decision Section */}
<div className="space-y-3 border-t pt-4">
<h3 className="font-semibold text-lg">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-[120px]"
/>
<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-3 flex items-start gap-2">
<XCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p>
</div>
)}
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end px-6 pb-6 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 (Cancel Request)
</>
)}
</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 (Continue to Dept Lead)
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,15 @@
/**
* Dealer Claim Request Detail Modals
*
* These modals are specific to Dealer Claim request details.
* Located in: src/dealer-claim/components/request-detail/modals/
*/
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 { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';

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

@ -0,0 +1,34 @@
/**
* 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';
// Re-export types
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';

View File

@ -0,0 +1,730 @@
/**
* 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';
// 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 { user } = useAuth();
// Custom hooks
const {
request,
apiRequest,
loading: requestLoading,
refreshing,
refreshDetails,
currentApprovalLevel,
isSpectator,
isInitiator,
existingParticipants,
accessDenied,
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
// Determine if user is initiator
const currentUserId = (user as any)?.userId || '';
const currentUserEmail = (user as any)?.email?.toLowerCase() || '';
const initiatorUserId = apiRequest?.initiator?.userId;
const initiatorEmail = apiRequest?.initiator?.email?.toLowerCase();
const isUserInitiator = apiRequest?.initiator && (
(initiatorUserId && initiatorUserId === currentUserId) ||
(initiatorEmail && initiatorEmail === currentUserEmail)
);
// Determine if user is department lead (whoever is in step 3 / approval level 3)
// Use approvalFlow (transformed) or approvals (raw) - both have step/levelNumber
const approvalFlow = apiRequest?.approvalFlow || [];
const approvals = apiRequest?.approvals || [];
// Try to find Step 3 from approvalFlow first (has 'step' property), then from approvals (has 'levelNumber')
const step3Level = approvalFlow.find((level: any) =>
(level.step || level.levelNumber || level.level_number) === 3
) || approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 3
);
const deptLeadUserId = step3Level?.approverId || step3Level?.approver_id || step3Level?.approver?.userId;
const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver_email || step3Level?.approver?.email || '').toLowerCase().trim();
// User is department lead if they match the Step 3 approver (regardless of status)
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
const step3Status = step3Level?.status ? String(step3Level.status).toUpperCase() : '';
const isStep3PendingOrInProgress = step3Status === 'PENDING' ||
step3Status === 'IN_PROGRESS' ||
step3Status === 'PAUSED';
const currentLevel = apiRequest?.currentLevel || apiRequest?.current_level || apiRequest?.currentStep || 0;
const isStep3CurrentLevel = currentLevel === 3;
// User is current approver for Step 3 if Step 3 is active and they are the approver
const isStep3CurrentApprover = step3Level && isStep3PendingOrInProgress && isStep3CurrentLevel && (
(deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail)
);
// IO tab visibility for dealer claims
// Show IO tab if user is initiator, department lead (Step 3 approver), or current Step 3 approver
// Also show if Step 3 has been approved (to view IO details)
const isStep3Approved = step3Status === 'APPROVED';
const showIOTab = isUserInitiator || isDeptLead || isStep3CurrentApprover || isStep3Approved;
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);
// 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;
// Debug logging
console.debug('[DealerClaimRequestDetail] Closure check:', {
requestStatus,
requestStatusRaw: request?.status,
apiRequestStatusRaw: apiRequest?.status,
isInitiator,
needsClosure,
});
const {
conclusionRemark,
setConclusionRemark,
conclusionLoading,
conclusionSubmitting,
aiGenerated,
handleGenerateConclusion,
handleFinalizeConclusion,
} = useConclusionRemark(
request,
requestIdentifier,
isInitiator,
refreshDetails,
onBack,
setActionStatus,
setShowActionStatusModal
);
// 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' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
// 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 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}
/>
</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}
/>
</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}
/>
</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={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}
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}
/>
</>
);
}
/**
* Dealer Claim RequestDetail Component (Exported)
*/
export function DealerClaimRequestDetail(props: RequestDetailProps) {
return (
<RequestDetailErrorBoundary>
<DealerClaimRequestDetailInner {...props} />
</RequestDetailErrorBoundary>
);
}

93
src/flows.ts Normal file
View File

@ -0,0 +1,93 @@
/**
* 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 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;
}
}
// Re-export flow modules for direct access
export { CustomFlow, DealerClaimFlow, SharedComponents };
export type { RequestFlowType } from '@/utils/requestTypeUtils';

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';
@ -229,6 +230,87 @@ export function useRequestDetails(
console.debug('Pause details not available:', error); 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 {
console.debug('[useRequestDetails] Fetching claim details for requestId:', wf.requestId);
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
console.debug('[useRequestDetails] Claim API response:', {
status: claimResponse.status,
hasData: !!claimResponse.data,
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
fullResponse: claimResponse.data,
});
const claimData = claimResponse.data?.data || claimResponse.data;
console.debug('[useRequestDetails] Extracted claimData:', {
hasClaimData: !!claimData,
claimDataKeys: claimData ? Object.keys(claimData) : [],
hasClaimDetails: !!(claimData?.claimDetails || claimData?.claim_details),
hasProposalDetails: !!(claimData?.proposalDetails || claimData?.proposal_details),
hasCompletionDetails: !!(claimData?.completionDetails || claimData?.completion_details),
hasInternalOrder: !!(claimData?.internalOrder || claimData?.internal_order),
});
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;
}
console.debug('[useRequestDetails] Extracted details:', {
claimDetails: claimDetails ? {
hasActivityName: !!(claimDetails.activityName || claimDetails.activity_name),
hasActivityType: !!(claimDetails.activityType || claimDetails.activity_type),
hasLocation: !!(claimDetails.location),
activityName: claimDetails.activityName || claimDetails.activity_name,
activityType: claimDetails.activityType || claimDetails.activity_type,
location: claimDetails.location,
allKeys: Object.keys(claimDetails),
} : null,
hasProposalDetails: !!proposalDetails,
hasCompletionDetails: !!completionDetails,
hasInternalOrder: !!internalOrder,
hasBudgetTracking: !!budgetTracking,
hasInvoice: !!invoice,
hasCreditNote: !!creditNote,
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
});
} 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,
});
}
}
/** /**
* Build: Complete request object with all transformed data * Build: Complete request object with all transformed data
* This object is used throughout the UI * This object is used throughout the UI
@ -242,12 +324,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 +352,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);
@ -441,6 +537,66 @@ export function useRequestDetails(
console.debug('Pause details not available:', error); 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 {
console.debug('[useRequestDetails] Initial load - Fetching claim details for requestId:', wf.requestId);
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
console.debug('[useRequestDetails] Initial load - Claim API response:', {
status: claimResponse.status,
hasData: !!claimResponse.data,
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
});
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;
}
console.debug('[useRequestDetails] Initial load - Extracted details:', {
hasClaimDetails: !!claimDetails,
claimDetailsKeys: claimDetails ? Object.keys(claimDetails) : [],
hasProposalDetails: !!proposalDetails,
hasCompletionDetails: !!completionDetails,
hasInternalOrder: !!internalOrder,
hasBudgetTracking: !!budgetTracking,
hasInvoice: !!invoice,
hasCreditNote: !!creditNote,
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
});
}
} 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
const mapped = { const mapped = {
id: wf.requestNumber || wf.requestId, id: wf.requestNumber || wf.requestId,
@ -449,6 +605,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 +629,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 +806,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

@ -68,7 +68,16 @@ 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={() => {
const { navigateToRequest } = require('@/utils/requestNavigation');
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,14 @@ export function ApproverPerformanceRequestList({
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate(`/request/${request.requestId}`); const { navigateToRequest } = require('@/utils/requestNavigation');
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

@ -61,6 +61,32 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
> >
{request.priority} {request.priority}
</Badge> </Badge>
{/* Template Type Badge */}
{(() => {
const templateType = (request as any)?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType
let templateLabel = 'Custom';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Claim Management';
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

@ -69,7 +69,11 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
}, [onBack, navigate]); }, [onBack, navigate]);
const handleViewRequest = useCallback((requestId: string) => { const handleViewRequest = useCallback((requestId: string) => {
navigate(`/request/${requestId}`); const { navigateToRequest } = require('@/utils/requestNavigation');
navigateToRequest({
requestId,
navigate,
});
}, [navigate]); }, [navigate]);
// Export handlers // Export handlers

View File

@ -5,7 +5,7 @@
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ArrowRight, User, TrendingUp, Clock, FileText, Pause } from 'lucide-react'; import { ArrowRight, User, TrendingUp, Clock, Pause } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { MyRequest } from '../types/myRequests.types'; import { MyRequest } from '../types/myRequests.types';
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
@ -97,16 +97,32 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<PriorityIcon className="w-3 h-3 mr-1" /> <PriorityIcon className="w-3 h-3 mr-1" />
{request.priority} {request.priority}
</Badge> </Badge>
{request.templateType && ( {/* Template Type Badge */}
<Badge {(() => {
variant="secondary" const templateType = request?.templateType || (request as any)?.template_type || '';
className="bg-purple-100 text-purple-700 text-xs shrink-0 hidden sm:inline-flex" const templateTypeUpper = templateType?.toUpperCase() || '';
data-testid="template-badge"
> // Direct mapping from templateType
<FileText className="w-3 h-3 mr-1" /> let templateLabel = 'Custom';
Template: {request.templateName} let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
</Badge>
)} if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Claim Management';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template';
}
return (
<Badge
variant="outline"
className={`${templateColor} font-medium text-xs shrink-0`}
data-testid="template-type-badge"
>
{templateLabel}
</Badge>
);
})()}
</div> </div>
<p className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2 leading-relaxed" data-testid="request-description"> <p className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2 leading-relaxed" data-testid="request-description">
{stripHtmlTags(request.description || '') || 'No description provided'} {stripHtmlTags(request.description || '') || 'No description provided'}

View File

@ -16,6 +16,7 @@ export interface MyRequest {
currentApprover?: string; currentApprover?: string;
approverLevel?: string; approverLevel?: string;
templateType?: string; templateType?: string;
workflowType?: string;
templateName?: string; templateName?: string;
pauseInfo?: { pauseInfo?: {
isPaused: boolean; isPaused: boolean;

View File

@ -29,8 +29,9 @@ export function transformRequest(req: any): MyRequest {
: req.currentStep && req.totalSteps : req.currentStep && req.totalSteps
? `${req.currentStep} of ${req.totalSteps}` ? `${req.currentStep} of ${req.totalSteps}`
: '—', : '—',
templateType: req.templateType, templateType: req.templateType || req.template_type,
templateName: req.templateName, workflowType: req.workflowType || req.workflow_type,
templateName: req.templateName || req.template_name,
}; };
} }

View File

@ -28,6 +28,7 @@ interface Request {
currentLevelSLA?: any; // Backend-provided SLA for current level currentLevelSLA?: any; // Backend-provided SLA for current level
isPaused?: boolean; // Pause status isPaused?: boolean; // Pause status
pauseInfo?: any; // Pause details pauseInfo?: any; // Pause details
templateType?: string; // Template type for badge display
} }
interface OpenRequestsProps { interface OpenRequestsProps {
@ -178,6 +179,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
approvalStep: r.currentLevel ? `Step ${r.currentLevel} of ${r.totalLevels || '?'}` : undefined, approvalStep: r.currentLevel ? `Step ${r.currentLevel} of ${r.totalLevels || '?'}` : undefined,
department: r.department, department: r.department,
currentLevelSLA: r.currentLevelSLA, // ← Backend-calculated current level SLA currentLevelSLA: r.currentLevelSLA, // ← Backend-calculated current level SLA
templateType: r.templateType || r.template_type, // ← Template type for badge display
}; };
}); });
setItems(mapped); setItems(mapped);
@ -458,6 +460,32 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
> >
{request.priority} {request.priority}
</Badge> </Badge>
{/* Template Type Badge */}
{(() => {
const templateType = (request as any)?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType
let templateLabel = 'Custom';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Claim Management';
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

@ -1,60 +1,26 @@
/** /**
* RequestDetail Component * RequestDetail Router Component
* *
* Purpose: Display and manage detailed view of a workflow request * Purpose: Routes to the appropriate flow-specific RequestDetail screen
* *
* Architecture: * Architecture:
* - Uses custom hooks for complex logic (data fetching, socket, document upload, etc.) * - This is a router that determines the flow type and renders the appropriate screen
* - Delegates UI rendering to specialized tab components * - Each flow has its own complete RequestDetail screen in its folder
* - Error boundary for graceful error handling * - Deleting a flow folder removes all related code (truly modular)
* - Real-time WebSocket integration *
* IMPORTANT: This file only routes. All actual implementation is in flow-specific folders.
* Deleting src/custom/ or src/dealer-claim/ removes ALL related code.
*/ */
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { AlertTriangle, RefreshCw } from 'lucide-react';
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 { useRequestDetails } from '@/hooks/useRequestDetails';
import { useRequestSocket } from '@/hooks/useRequestSocket'; import { useAuth } from '@/contexts/AuthContext';
import { useDocumentUpload } from '@/hooks/useDocumentUpload'; import { getRequestFlowType } from '@/utils/requestTypeUtils';
import { useConclusionRemark } from '@/hooks/useConclusionRemark'; import { getRequestDetailScreen } from '@/flows';
import { useModalManager } from '@/hooks/useModalManager';
import { downloadDocument } from '@/services/workflowApi';
// Components
import { RequestDetailHeader } from './components/RequestDetailHeader';
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi';
import { toast } from 'sonner';
import { OverviewTab } from './components/tabs/OverviewTab';
import { WorkflowTab } from './components/tabs/WorkflowTab';
import { DocumentsTab } from './components/tabs/DocumentsTab';
import { ActivityTab } from './components/tabs/ActivityTab';
import { WorkNotesTab } from './components/tabs/WorkNotesTab';
import { SummaryTab } from './components/tabs/SummaryTab';
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
import { RequestDetailModals } from './components/RequestDetailModals';
import { RequestDetailProps } from './types/requestDetail.types'; import { RequestDetailProps } from './types/requestDetail.types';
import { PauseModal } from '@/components/workflow/PauseModal';
import { ResumeModal } from '@/components/workflow/ResumeModal';
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
/** /**
* Error Boundary Component * Error Boundary Component
@ -70,7 +36,7 @@ class RequestDetailErrorBoundary extends Component<{ children: ReactNode }, { ha
} }
override componentDidCatch(error: Error, errorInfo: ErrorInfo) { override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('RequestDetail Error:', error, errorInfo); console.error('RequestDetail Router Error:', error, errorInfo);
} }
override render() { override render() {
@ -96,209 +62,24 @@ class RequestDetailErrorBoundary extends Component<{ children: ReactNode }, { ha
} }
/** /**
* RequestDetailInner Component * RequestDetailRouter Component
*
* Routes to the appropriate flow-specific RequestDetail screen based on request type.
* This ensures complete modularity - deleting a flow folder removes all related code.
*/ */
function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests = [] }: RequestDetailProps) { function RequestDetailRouter({ requestId: propRequestId, onBack, dynamicRequests = [] }: RequestDetailProps) {
const params = useParams<{ requestId: string }>(); const params = useParams<{ requestId: string }>();
const requestIdentifier = params.requestId || propRequestId || ''; 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 { user } = useAuth(); const { user } = useAuth();
// Custom hooks // Fetch request details to determine flow type
const { const {
request,
apiRequest, apiRequest,
loading: requestLoading, loading: requestLoading,
refreshing,
refreshDetails,
currentApprovalLevel,
isSpectator,
isInitiator,
existingParticipants,
accessDenied,
} = useRequestDetails(requestIdentifier, dynamicRequests, user); } = useRequestDetails(requestIdentifier, dynamicRequests, user);
const { // Loading state while determining flow type
mergedMessages, if (requestLoading && !apiRequest) {
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,
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
// 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 () => {
// Wait for refresh to complete to show updated status
await refreshDetails();
};
const handleRetrigger = () => {
setShowRetriggerModal(true);
};
const handlePauseSuccess = async () => {
// Wait for refresh to complete to show updated pause status
await refreshDetails();
};
const handleRetriggerSuccess = async () => {
// Wait for refresh to complete
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;
}
// Open share modal with the existing summary ID
// Summary should already exist from closure (auto-created by backend)
setShowShareSummaryModal(true);
};
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
// Check if request is closed (or needs closure for approved/rejected)
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
// Fetch summary details if request is closed
// Summary is automatically created by backend when request is closed (on final approval)
useEffect(() => {
const fetchSummaryDetails = async () => {
if (!isClosed || !apiRequest?.requestId) {
setSummaryDetails(null);
setSummaryId(null);
return;
}
try {
setLoadingSummary(true);
// Just fetch the summary by requestId - don't try to create it
// Summary is auto-created by backend on final approval/rejection
const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) {
setSummaryId(summary.summaryId);
// Fetch full summary details
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 {
// Summary doesn't exist yet - this is normal if request just closed
setSummaryDetails(null);
setSummaryId(null);
}
} catch (error: any) {
// Summary not found - this is OK, summary may not exist yet
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 ( return (
<div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state"> <div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state">
<div className="text-center"> <div className="text-center">
@ -309,359 +90,36 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
); );
} }
// Access Denied state // Determine flow type and get the appropriate RequestDetail screen
if (accessDenied?.denied) { const flowType = getRequestFlowType(apiRequest);
return ( const RequestDetailScreen = getRequestDetailScreen(flowType);
<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="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 text-left">
<p className="text-sm text-amber-800">
<strong>Who can access this request?</strong>
</p>
<ul className="text-sm text-amber-700 mt-2 space-y-1">
<li> The person who created this request (Initiator)</li>
<li> Designated approvers at any level</li>
<li> Added spectators or participants</li>
<li> Organization administrators</li>
</ul>
</div>
<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">Request Not Found</h2>
<p className="text-gray-600 mb-6">
The 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>
);
}
// Render the flow-specific RequestDetail screen
// Each flow has its own complete implementation in its folder
return ( return (
<> <RequestDetailScreen
<div className="min-h-screen bg-gray-50" data-testid="request-detail-page"> requestId={propRequestId}
<div className="max-w-7xl mx-auto"> onBack={onBack}
{/* Header Section */} dynamicRequests={dynamicRequests}
<RequestDetailHeader />
request={request}
refreshing={refreshing}
onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh}
onShareSummary={handleShareSummary}
isInitiator={isInitiator}
/>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="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">
<OverviewTab
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}
/>
</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">
<WorkflowTab
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}
/>
</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}
/>
)}
</div>
</Tabs>
</div>
</div>
{/* Share Summary Modal */}
{showShareSummaryModal && summaryId && (
<ShareSummaryModal
isOpen={showShareSummaryModal}
onClose={() => setShowShareSummaryModal(false)}
summaryId={summaryId}
requestTitle={request?.title || 'N/A'}
onSuccess={() => {
refreshDetails();
// Trigger refresh of shared recipients list
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}
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}
/>
</>
); );
} }
/** /**
* RequestDetail Component (Exported) * RequestDetail Component (Exported)
*
* This is now a router that delegates to flow-specific RequestDetail screens.
* Each flow has its own complete RequestDetail implementation in its folder.
*
* To remove a flow type completely:
* 1. Delete the flow folder (e.g., src/dealer-claim/)
* 2. Remove it from src/flows.ts FlowRegistry
* 3. That's it! All related code is gone.
*/ */
export function RequestDetail(props: RequestDetailProps) { export function RequestDetail(props: RequestDetailProps) {
return ( return (
<RequestDetailErrorBoundary> <RequestDetailErrorBoundary>
<RequestDetailInner {...props} /> <RequestDetailRouter {...props} />
</RequestDetailErrorBoundary> </RequestDetailErrorBoundary>
); );
} }

View File

@ -2,7 +2,7 @@
* Quick Actions Sidebar Component * Quick Actions Sidebar Component
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
@ -10,6 +10,9 @@ import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle }
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi'; import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import notificationApi, { type Notification } from '@/services/notificationApi'; import notificationApi, { type Notification } from '@/services/notificationApi';
import { ProcessDetailsCard } from '@/dealer-claim/components/request-detail/claim-cards';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import { determineUserRole, getRoleBasedVisibility, mapToClaimManagementRequest } from '@/utils/claimDataMapper';
interface QuickActionsSidebarProps { interface QuickActionsSidebarProps {
request: any; request: any;
@ -27,6 +30,8 @@ interface QuickActionsSidebarProps {
refreshTrigger?: number; // Trigger to refresh shared recipients list refreshTrigger?: number; // Trigger to refresh shared recipients list
pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility) pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility)
currentUserId?: string; // Current user's ID (kept for backwards compatibility) currentUserId?: string; // Current user's ID (kept for backwards compatibility)
apiRequest?: any;
onEditClaimAmount?: () => void;
} }
export function QuickActionsSidebar({ export function QuickActionsSidebar({
@ -43,6 +48,10 @@ export function QuickActionsSidebar({
onRetrigger, onRetrigger,
summaryId, summaryId,
refreshTrigger, refreshTrigger,
pausedByUserId: pausedByUserIdProp,
currentUserId: currentUserIdProp,
apiRequest,
onEditClaimAmount,
}: QuickActionsSidebarProps) { }: QuickActionsSidebarProps) {
const { user } = useAuth(); const { user } = useAuth();
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]); const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
@ -50,8 +59,8 @@ export function QuickActionsSidebar({
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false); const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
const isClosed = request?.status === 'closed'; const isClosed = request?.status === 'closed';
const isPaused = request?.pauseInfo?.isPaused || false; const isPaused = request?.pauseInfo?.isPaused || false;
const pausedByUserId = request?.pauseInfo?.pausedBy?.userId; const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
const currentUserId = (user as any)?.userId || ''; const currentUserId = currentUserIdProp || (user as any)?.userId || '';
// Both approver AND initiator can pause (when not already paused and not closed) // Both approver AND initiator can pause (when not already paused and not closed)
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator); const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
@ -117,6 +126,16 @@ export function QuickActionsSidebar({
fetchSharedRecipients(); fetchSharedRecipients();
}, [isClosed, summaryId, isInitiator, refreshTrigger]); }, [isClosed, summaryId, isInitiator, refreshTrigger]);
// Claim details for sidebar (only for claim management requests)
const claimSidebarData = useMemo(() => {
if (!apiRequest || !isClaimManagementRequest(apiRequest)) return null;
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
if (!claimRequest) return null;
const userRole = determineUserRole(apiRequest, currentUserId);
const visibility = getRoleBasedVisibility(userRole);
return { claimRequest, visibility };
}, [apiRequest, currentUserId]);
return ( return (
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4 sm:space-y-6">
{/* Quick Actions Card - Hide entire card for spectators and closed requests */} {/* Quick Actions Card - Hide entire card for spectators and closed requests */}
@ -339,6 +358,21 @@ export function QuickActionsSidebar({
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Process details anchored at the bottom of the action sidebar for claim workflows */}
{claimSidebarData && (
<ProcessDetailsCard
ioDetails={claimSidebarData.claimRequest.ioDetails}
dmsDetails={claimSidebarData.claimRequest.dmsDetails}
claimAmount={{
amount: claimSidebarData.claimRequest.claimAmount.closed || claimSidebarData.claimRequest.claimAmount.estimated || 0,
}}
estimatedBudgetBreakdown={claimSidebarData.claimRequest.proposalDetails?.costBreakup}
closedExpensesBreakdown={claimSidebarData.claimRequest.activityInfo?.closedExpensesBreakdown}
visibility={claimSidebarData.visibility}
onEditClaimAmount={onEditClaimAmount}
/>
)}
</div> </div>
); );
} }

View File

@ -15,12 +15,23 @@ interface RequestDetailHeaderProps {
onRefresh: () => void; onRefresh: () => void;
onShareSummary?: () => void; onShareSummary?: () => void;
isInitiator?: boolean; isInitiator?: boolean;
// Plug-and-play: Pass SLA data directly from module
slaData?: any; // SLAData | null - passed from module's business logic
isPaused?: boolean; // Pass pause status from module
} }
export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, onShareSummary, isInitiator }: RequestDetailHeaderProps) { export function RequestDetailHeader({
request,
refreshing,
onBack,
onRefresh,
onShareSummary,
isInitiator,
slaData, // Module passes prepared SLA data
isPaused = false // Module passes pause status
}: RequestDetailHeaderProps) {
const priorityConfig = getPriorityConfig(request?.priority || 'standard'); const priorityConfig = getPriorityConfig(request?.priority || 'standard');
const statusConfig = getStatusConfig(request?.status || 'pending'); const statusConfig = getStatusConfig(request?.status || 'pending');
const isPaused = request?.pauseInfo?.isPaused || false;
return ( return (
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header"> <div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
@ -50,7 +61,7 @@ export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, on
<h1 className="text-sm sm:text-base md:text-lg font-bold text-gray-900 truncate" data-testid="request-id"> <h1 className="text-sm sm:text-base md:text-lg font-bold text-gray-900 truncate" data-testid="request-id">
{request.id || 'N/A'} {request.id || 'N/A'}
</h1> </h1>
{/* Priority and Status Badges */} {/* Priority, Status, and Template Type Badges */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2"> <div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge <Badge
className={`${priorityConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`} className={`${priorityConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`}
@ -66,6 +77,26 @@ export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, on
> >
{statusConfig.label} {statusConfig.label}
</Badge> </Badge>
{/* Template Type Badge */}
{(() => {
const workflowType = request?.workflowType || request?.workflow_type;
const templateType = request?.templateType || request?.template_type;
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT' || templateType === 'claim-management';
const templateLabel = isClaimManagement ? 'Claim Management' : 'Custom';
const templateColor = isClaimManagement
? 'bg-blue-100 !text-blue-700 border-blue-200'
: 'bg-purple-100 !text-purple-600 border-purple-200';
return (
<Badge
className={`${templateColor} rounded-full px-2 sm:px-3 text-xs shrink-0`}
variant="outline"
data-testid="template-type-badge"
>
{templateLabel}
</Badge>
);
})()}
</div> </div>
</div> </div>
</div> </div>
@ -109,17 +140,19 @@ export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, on
</div> </div>
</div> </div>
{/* SLA Progress Section */} {/* SLA Progress Section - Plug-and-play: Module passes prepared SLA data */}
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${ {slaData !== undefined && (
isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50' <div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${
}`} data-testid="sla-section"> isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
<SLAProgressBar }`} data-testid="sla-section">
sla={request.summary?.sla || request.sla} <SLAProgressBar
requestStatus={request.status} sla={slaData}
isPaused={isPaused} requestStatus={request.status}
testId="request-sla" isPaused={isPaused}
/> testId="request-sla"
</div> />
</div>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,31 @@
/**
* Claim Management TypeScript interfaces
* Types for Claim Management request components
*/
export interface ClaimActivityInfo {
activityName: string;
activityType: string;
requestedDate?: string;
location: string;
period?: {
startDate: string;
endDate: string;
};
estimatedBudget?: number;
closedExpenses?: number;
closedExpensesBreakdown?: Array<{
description: string;
amount: number;
}>;
description?: string;
}
export interface DealerInfo {
dealerCode: string;
dealerName: string;
email?: string;
phone?: string;
address?: string;
}

View File

@ -96,6 +96,32 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<PriorityIcon className="w-3 h-3 mr-1" /> <PriorityIcon className="w-3 h-3 mr-1" />
{request.priority} {request.priority}
</Badge> </Badge>
{/* Template Type Badge */}
{(() => {
const templateType = request?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType
let templateLabel = 'Custom';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Claim Management';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template';
}
return (
<Badge
variant="outline"
className={`${templateColor} font-medium text-xs shrink-0`}
data-testid="template-type-badge"
>
{templateLabel}
</Badge>
);
})()}
{request.department && ( {request.department && (
<Badge <Badge
variant="secondary" variant="secondary"

View File

@ -62,6 +62,7 @@ export interface ConvertedRequest {
currentApprover: string; currentApprover: string;
approverLevel: string; approverLevel: string;
templateType?: string; templateType?: string;
workflowType?: string;
templateName?: string; templateName?: string;
} }

View File

@ -97,8 +97,9 @@ export function transformRequest(req: any): ConvertedRequest {
createdAt: createdAt, createdAt: createdAt,
currentApprover: currentApprover, currentApprover: currentApprover,
approverLevel: approverLevel, approverLevel: approverLevel,
templateType: req.templateType, templateType: req.templateType || req.template_type,
templateName: req.templateName workflowType: req.workflowType || req.workflow_type,
templateName: req.templateName || req.template_name
}; };
} }

72
src/services/dealerApi.ts Normal file
View File

@ -0,0 +1,72 @@
/**
* Dealer API Service
* Handles API calls for dealer-related operations
*/
import apiClient from './authApi';
export interface DealerInfo {
userId: string;
email: string;
dealerCode: string;
dealerName: string;
displayName: string;
phone?: string;
department?: string;
designation?: string;
}
/**
* Get all dealers
*/
export async function getAllDealers(): Promise<DealerInfo[]> {
try {
const res = await apiClient.get('/dealers');
return res.data?.data || res.data || [];
} catch (error) {
console.error('[DealerAPI] Error fetching dealers:', error);
throw error;
}
}
/**
* Get dealer by code
*/
export async function getDealerByCode(dealerCode: string): Promise<DealerInfo | null> {
try {
const res = await apiClient.get(`/dealers/code/${dealerCode}`);
return res.data?.data || res.data || null;
} catch (error) {
console.error('[DealerAPI] Error fetching dealer by code:', error);
return null;
}
}
/**
* Get dealer by email
*/
export async function getDealerByEmail(email: string): Promise<DealerInfo | null> {
try {
const res = await apiClient.get(`/dealers/email/${encodeURIComponent(email)}`);
return res.data?.data || res.data || null;
} catch (error) {
console.error('[DealerAPI] Error fetching dealer by email:', error);
return null;
}
}
/**
* Search dealers
*/
export async function searchDealers(searchTerm: string): Promise<DealerInfo[]> {
try {
const res = await apiClient.get('/dealers/search', {
params: { q: searchTerm },
});
return res.data?.data || res.data || [];
} catch (error) {
console.error('[DealerAPI] Error searching dealers:', error);
return [];
}
}

View File

@ -0,0 +1,306 @@
/**
* Dealer Claim API Service
* Handles API calls for dealer claim management operations
*/
import apiClient from './authApi';
export interface CreateClaimRequestPayload {
activityName: string;
activityType: string;
dealerCode: string;
dealerName: string;
dealerEmail?: string;
dealerPhone?: string;
dealerAddress?: string;
activityDate?: string; // ISO date string
location: string;
requestDescription: string;
periodStartDate?: string; // ISO date string
periodEndDate?: string; // ISO date string
estimatedBudget?: string | number;
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
}
export interface ClaimRequestResponse {
request: {
requestId: string;
requestNumber: string;
title: string;
description: string;
status: string;
workflowType: string;
// ... other fields
};
message: string;
}
/**
* Create a new dealer claim request
* POST /api/v1/dealer-claims
*/
export async function createClaimRequest(payload: CreateClaimRequestPayload): Promise<ClaimRequestResponse> {
try {
const response = await apiClient.post('/dealer-claims', payload);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error creating claim request:', error);
throw error;
}
}
/**
* Get claim details
* GET /api/v1/dealer-claims/:requestId
*/
export async function getClaimDetails(requestId: string): Promise<any> {
try {
const response = await apiClient.get(`/dealer-claims/${requestId}`);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error fetching claim details:', error);
throw error;
}
}
/**
* Submit dealer proposal (Step 1)
* POST /api/v1/dealer-claims/:requestId/proposal
*/
export async function submitProposal(
requestId: string,
proposalData: {
proposalDocument?: File;
costBreakup?: Array<{ description: string; amount: number }>;
totalEstimatedBudget?: number;
timelineMode?: 'date' | 'days';
expectedCompletionDate?: string;
expectedCompletionDays?: number;
dealerComments?: string;
}
): Promise<any> {
try {
const formData = new FormData();
if (proposalData.proposalDocument) {
formData.append('proposalDocument', proposalData.proposalDocument);
}
if (proposalData.costBreakup) {
formData.append('costBreakup', JSON.stringify(proposalData.costBreakup));
}
if (proposalData.totalEstimatedBudget !== undefined) {
formData.append('totalEstimatedBudget', proposalData.totalEstimatedBudget.toString());
}
if (proposalData.timelineMode) {
formData.append('timelineMode', proposalData.timelineMode);
}
if (proposalData.expectedCompletionDate) {
formData.append('expectedCompletionDate', proposalData.expectedCompletionDate);
}
if (proposalData.expectedCompletionDays !== undefined) {
formData.append('expectedCompletionDays', proposalData.expectedCompletionDays.toString());
}
if (proposalData.dealerComments) {
formData.append('dealerComments', proposalData.dealerComments);
}
const response = await apiClient.post(`/dealer-claims/${requestId}/proposal`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error submitting proposal:', error);
throw error;
}
}
/**
* Submit completion documents (Step 5)
* POST /api/v1/dealer-claims/:requestId/completion
*/
export async function submitCompletion(
requestId: string,
completionData: {
activityCompletionDate: string; // ISO date string
numberOfParticipants?: number;
closedExpenses?: Array<{ description: string; amount: number }>;
totalClosedExpenses?: number;
completionDocuments?: File[];
activityPhotos?: File[];
}
): Promise<any> {
try {
const formData = new FormData();
formData.append('activityCompletionDate', completionData.activityCompletionDate);
if (completionData.numberOfParticipants !== undefined) {
formData.append('numberOfParticipants', completionData.numberOfParticipants.toString());
}
if (completionData.closedExpenses) {
formData.append('closedExpenses', JSON.stringify(completionData.closedExpenses));
}
if (completionData.totalClosedExpenses !== undefined) {
formData.append('totalClosedExpenses', completionData.totalClosedExpenses.toString());
}
if (completionData.completionDocuments) {
completionData.completionDocuments.forEach((file) => {
formData.append('completionDocuments', file);
});
}
if (completionData.activityPhotos) {
completionData.activityPhotos.forEach((file) => {
formData.append('activityPhotos', file);
});
}
const response = await apiClient.post(`/dealer-claims/${requestId}/completion`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error submitting completion:', error);
throw error;
}
}
/**
* Validate/Fetch IO details from SAP (returns dummy data for now)
* GET /api/v1/dealer-claims/:requestId/io/validate?ioNumber=IO1234
* This only validates and returns IO details, does not store anything
*/
export async function validateIO(
requestId: string,
ioNumber: string
): Promise<{
ioNumber: string;
availableBalance: number;
currency: string;
isValid: boolean;
}> {
try {
const response = await apiClient.get(`/dealer-claims/${requestId}/io/validate`, {
params: { ioNumber }
});
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error validating IO:', error);
throw error;
}
}
/**
* Update IO details and block amount (Step 3)
* PUT /api/v1/dealer-claims/:requestId/io
* Only stores data when blocking amount > 0
*/
export async function updateIODetails(
requestId: string,
ioData: {
ioNumber: string;
ioRemark?: string;
ioAvailableBalance?: number;
ioBlockedAmount?: number;
ioRemainingBalance?: number;
}
): Promise<any> {
try {
// Map frontend field names to backend expected field names
// Only include balance fields if they are explicitly provided (not undefined)
// This prevents overwriting existing balance values when only updating ioNumber/ioRemark
const payload: any = {
ioNumber: ioData.ioNumber,
ioRemark: ioData.ioRemark || '',
};
// Only include balance fields if explicitly provided
if (ioData.ioAvailableBalance !== undefined) {
payload.availableBalance = ioData.ioAvailableBalance;
}
if (ioData.ioBlockedAmount !== undefined) {
payload.blockedAmount = ioData.ioBlockedAmount;
}
if (ioData.ioRemainingBalance !== undefined) {
payload.remainingBalance = ioData.ioRemainingBalance;
}
const response = await apiClient.put(`/dealer-claims/${requestId}/io`, payload);
return response.data?.data || response.data;
} catch (error) {
console.error('Error updating IO details:', error);
throw error;
}
}
/**
* Update E-Invoice details (Step 7)
* PUT /api/v1/dealer-claims/:requestId/e-invoice
*/
export async function updateEInvoice(
requestId: string,
eInvoiceData: {
eInvoiceNumber?: string;
eInvoiceDate: string; // ISO date string
dmsNumber?: string;
}
): Promise<any> {
try {
const response = await apiClient.put(`/dealer-claims/${requestId}/e-invoice`, eInvoiceData);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error updating e-invoice:', error);
throw error;
}
}
/**
* Update Credit Note details (Step 8)
* PUT /api/v1/dealer-claims/:requestId/credit-note
*/
export async function updateCreditNote(
requestId: string,
creditNoteData: {
creditNoteNumber?: string;
creditNoteDate: string; // ISO date string
creditNoteAmount: number;
}
): Promise<any> {
try {
const response = await apiClient.put(`/dealer-claims/${requestId}/credit-note`, creditNoteData);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error updating credit note:', error);
throw error;
}
}
/**
* Send credit note to dealer and auto-approve Step 8
* POST /api/v1/dealer-claims/:requestId/credit-note/send
*/
export async function sendCreditNoteToDealer(requestId: string): Promise<any> {
try {
const response = await apiClient.post(`/dealer-claims/${requestId}/credit-note/send`);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error sending credit note to dealer:', error);
throw error;
}
}

View File

@ -275,7 +275,7 @@ export async function listClosedByMe(params: { page?: number; limit?: number; se
}; };
} }
export async function getWorkflowDetails(requestId: string) { export async function getWorkflowDetails(requestId: string, _workflowType?: string) {
const res = await apiClient.get(`/workflows/${requestId}/details`); const res = await apiClient.get(`/workflows/${requestId}/details`);
return res.data?.data || res.data; return res.data?.data || res.data;
} }

View File

@ -0,0 +1,37 @@
/**
* Shared Components for Request Flows
*
* Common components that are reused across different request flow types.
* These components are flow-agnostic and provide consistent UI/UX.
*
* LOCATION: src/shared/components/
*/
// Import individual components
import { DocumentsTab } from './request-detail/DocumentsTab';
import { ActivityTab } from './request-detail/ActivityTab';
import { WorkNotesTab } from './request-detail/WorkNotesTab';
import { SummaryTab } from './request-detail/SummaryTab';
import { RequestDetailHeader } from './request-detail/RequestDetailHeader';
import { QuickActionsSidebar } from './request-detail/QuickActionsSidebar';
import { RequestDetailModals } from './request-detail/RequestDetailModals';
// Export individual components
export { DocumentsTab } from './request-detail/DocumentsTab';
export { ActivityTab } from './request-detail/ActivityTab';
export { WorkNotesTab } from './request-detail/WorkNotesTab';
export { SummaryTab } from './request-detail/SummaryTab';
export { RequestDetailHeader } from './request-detail/RequestDetailHeader';
export { QuickActionsSidebar } from './request-detail/QuickActionsSidebar';
export { RequestDetailModals } from './request-detail/RequestDetailModals';
// Export as a named object for convenience
export const SharedComponents = {
DocumentsTab,
ActivityTab,
WorkNotesTab,
SummaryTab,
RequestDetailHeader,
QuickActionsSidebar,
RequestDetailModals,
};

View File

@ -0,0 +1,9 @@
/**
* Shared Activity Tab
*
* This component is shared across all request flow types.
* Located in: src/shared/components/request-detail/
*/
// Re-export the original component
export { ActivityTab } from '@/pages/RequestDetail/components/tabs/ActivityTab';

View File

@ -0,0 +1,9 @@
/**
* Shared Documents Tab
*
* This component is shared across all request flow types.
* Located in: src/shared/components/request-detail/
*/
// Re-export the original component
export { DocumentsTab } from '@/pages/RequestDetail/components/tabs/DocumentsTab';

View File

@ -0,0 +1,9 @@
/**
* Shared Quick Actions Sidebar
*
* This component is shared across all request flow types.
* Located in: src/shared/components/request-detail/
*/
// Re-export the original component
export { QuickActionsSidebar } from '@/pages/RequestDetail/components/QuickActionsSidebar';

View File

@ -0,0 +1,9 @@
/**
* Shared Request Detail Header
*
* This component is shared across all request flow types.
* Located in: src/shared/components/request-detail/
*/
// Re-export the original component
export { RequestDetailHeader } from '@/pages/RequestDetail/components/RequestDetailHeader';

View File

@ -0,0 +1,9 @@
/**
* Shared Request Detail Modals
*
* These modals are shared across all request flow types.
* Located in: src/shared/components/request-detail/
*/
// Re-export the original component
export { RequestDetailModals } from '@/pages/RequestDetail/components/RequestDetailModals';

View File

@ -0,0 +1,9 @@
/**
* Shared Summary Tab
*
* This component is shared across all request flow types.
* Located in: src/shared/components/request-detail/
*/
// Re-export the original component
export { SummaryTab } from '@/pages/RequestDetail/components/tabs/SummaryTab';

View File

@ -0,0 +1,9 @@
/**
* Shared Work Notes Tab
*
* This component is shared across all request flow types.
* Located in: src/shared/components/request-detail/
*/
// Re-export the original component
export { WorkNotesTab } from '@/pages/RequestDetail/components/tabs/WorkNotesTab';

View File

@ -0,0 +1,387 @@
/**
* Claim Data Mapper Utilities
* Maps API response data to ClaimManagementRequest structure for frontend components
*/
import { isClaimManagementRequest } from './claimRequestUtils';
/**
* User roles in a claim management request
*/
export type RequestRole = 'INITIATOR' | 'DEALER' | 'DEPARTMENT_LEAD' | 'APPROVER' | 'SPECTATOR';
/**
* Claim Management Request structure for frontend
*/
export interface ClaimManagementRequest {
// Activity Information
activityInfo: {
activityName: string;
activityType: string;
requestedDate?: string;
location: string;
period?: {
startDate: string;
endDate: string;
};
estimatedBudget?: number;
closedExpenses?: number;
closedExpensesBreakdown?: Array<{ description: string; amount: number }>;
description?: string;
};
// Dealer Information
dealerInfo: {
dealerCode: string;
dealerName: string;
email?: string;
phone?: string;
address?: string;
};
// Proposal Details (Step 1)
proposalDetails?: {
proposalDocumentUrl?: string;
costBreakup: Array<{ description: string; amount: number }>;
totalEstimatedBudget: number;
timelineMode?: 'date' | 'days';
expectedCompletionDate?: string;
expectedCompletionDays?: number;
dealerComments?: string;
submittedAt?: string;
};
// IO Details (Step 3) - from internal_orders table
ioDetails?: {
ioNumber?: string;
ioRemark?: string;
availableBalance?: number;
blockedAmount?: number;
remainingBalance?: number;
organizedBy?: string;
organizedAt?: string;
};
// DMS Details (Step 7)
dmsDetails?: {
eInvoiceNumber?: string;
eInvoiceDate?: string;
dmsNumber?: string;
creditNoteNumber?: string;
creditNoteDate?: string;
creditNoteAmount?: number;
};
// Claim Amount
claimAmount: {
estimated: number;
closed: number;
};
}
/**
* Role-based visibility configuration
*/
export interface RoleVisibility {
showDealerInfo: boolean;
showProposalDetails: boolean;
showIODetails: boolean;
showDMSDetails: boolean;
showClaimAmount: boolean;
canEditClaimAmount: boolean;
}
/**
* Map API request data to ClaimManagementRequest structure
*/
export function mapToClaimManagementRequest(
apiRequest: any,
_currentUserId: string
): ClaimManagementRequest | null {
try {
if (!isClaimManagementRequest(apiRequest)) {
return null;
}
// Extract claim details from API response
const claimDetails = apiRequest.claimDetails || {};
const proposalDetails = apiRequest.proposalDetails || {};
const completionDetails = apiRequest.completionDetails || {};
const internalOrder = apiRequest.internalOrder || apiRequest.internal_order || {};
// Extract new normalized tables
const budgetTracking = apiRequest.budgetTracking || apiRequest.budget_tracking || {};
const invoice = apiRequest.invoice || {};
const creditNote = apiRequest.creditNote || apiRequest.credit_note || {};
const completionExpenses = apiRequest.completionExpenses || apiRequest.completion_expenses || [];
// Debug: Log raw claim details to help troubleshoot
console.debug('[claimDataMapper] Raw claimDetails:', claimDetails);
console.debug('[claimDataMapper] Raw apiRequest:', {
hasClaimDetails: !!apiRequest.claimDetails,
hasProposalDetails: !!apiRequest.proposalDetails,
hasCompletionDetails: !!apiRequest.completionDetails,
hasBudgetTracking: !!budgetTracking,
hasInvoice: !!invoice,
hasCreditNote: !!creditNote,
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
workflowType: apiRequest.workflowType,
});
// Map activity information (matching ActivityInformationCard expectations)
// Handle both camelCase and snake_case field names from Sequelize
const periodStartDate = claimDetails.periodStartDate || claimDetails.period_start_date;
const periodEndDate = claimDetails.periodEndDate || claimDetails.period_end_date;
const activityName = claimDetails.activityName || claimDetails.activity_name || '';
const activityType = claimDetails.activityType || claimDetails.activity_type || '';
const location = claimDetails.location || '';
console.debug('[claimDataMapper] Mapped activity fields:', {
activityName,
activityType,
location,
hasActivityName: !!activityName,
hasActivityType: !!activityType,
hasLocation: !!location,
});
// Get budget values from budgetTracking table (new source of truth)
const estimatedBudget = budgetTracking.proposalEstimatedBudget ||
budgetTracking.proposal_estimated_budget ||
budgetTracking.initialEstimatedBudget ||
budgetTracking.initial_estimated_budget ||
claimDetails.estimatedBudget ||
claimDetails.estimated_budget;
// Get closed expenses - check multiple sources with proper number conversion
const closedExpensesRaw = budgetTracking?.closedExpenses ||
budgetTracking?.closed_expenses ||
completionDetails?.totalClosedExpenses ||
completionDetails?.total_closed_expenses ||
claimDetails?.closedExpenses ||
claimDetails?.closed_expenses;
// Convert to number and handle 0 as valid value
const closedExpenses = closedExpensesRaw !== null && closedExpensesRaw !== undefined
? Number(closedExpensesRaw)
: undefined;
// Get closed expenses breakdown from new completionExpenses table
const closedExpensesBreakdown = Array.isArray(completionExpenses) && completionExpenses.length > 0
? completionExpenses.map((exp: any) => ({
description: exp.description || exp.itemDescription || '',
amount: Number(exp.amount) || 0
}))
: (completionDetails?.closedExpenses ||
completionDetails?.closed_expenses ||
completionDetails?.closedExpensesBreakdown ||
[]);
const activityInfo = {
activityName,
activityType,
requestedDate: claimDetails.activityDate || claimDetails.activity_date || apiRequest.createdAt, // Use activityDate as requestedDate, fallback to createdAt
location,
period: (periodStartDate && periodEndDate) ? {
startDate: periodStartDate,
endDate: periodEndDate,
} : undefined,
estimatedBudget,
closedExpenses,
closedExpensesBreakdown,
description: apiRequest.description || '', // Get description from workflow request
};
// Map dealer information (matching DealerInformationCard expectations)
// Dealer info should always be available from claimDetails (created during claim request creation)
// Handle both camelCase and snake_case from Sequelize JSON serialization
const dealerInfo = {
dealerCode: claimDetails?.dealerCode || claimDetails?.dealer_code || claimDetails?.DealerCode || '',
dealerName: claimDetails?.dealerName || claimDetails?.dealer_name || claimDetails?.DealerName || '',
email: claimDetails?.dealerEmail || claimDetails?.dealer_email || claimDetails?.DealerEmail || '',
phone: claimDetails?.dealerPhone || claimDetails?.dealer_phone || claimDetails?.DealerPhone || '',
address: claimDetails?.dealerAddress || claimDetails?.dealer_address || claimDetails?.DealerAddress || '',
};
// Log warning if dealer info is missing (should always be present for claim management requests)
if (!dealerInfo.dealerCode || !dealerInfo.dealerName) {
console.warn('[claimDataMapper] Dealer information is missing from claimDetails:', {
hasClaimDetails: !!claimDetails,
dealerCode: dealerInfo.dealerCode,
dealerName: dealerInfo.dealerName,
rawClaimDetails: claimDetails,
availableKeys: claimDetails ? Object.keys(claimDetails) : [],
});
}
// Map proposal details
const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date;
const proposal = proposalDetails ? {
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [],
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
expectedCompletionDate: expectedCompletionDate,
expectedCompletionDays: proposalDetails.expectedCompletionDays || proposalDetails.expected_completion_days,
timelineForClosure: expectedCompletionDate, // Map expectedCompletionDate to timelineForClosure for ProposalDetailsCard
dealerComments: proposalDetails.dealerComments || proposalDetails.dealer_comments,
submittedOn: proposalDetails.submittedAt || proposalDetails.submitted_at || proposalDetails.submittedOn,
} : undefined;
// Map IO details from dedicated internal_orders table
const ioDetails = {
ioNumber: internalOrder.ioNumber || internalOrder.io_number || claimDetails.ioNumber || claimDetails.io_number,
ioRemark: internalOrder.ioRemark || internalOrder.io_remark || '',
availableBalance: internalOrder.ioAvailableBalance || internalOrder.io_available_balance || claimDetails.ioAvailableBalance || claimDetails.io_available_balance,
blockedAmount: internalOrder.ioBlockedAmount || internalOrder.io_blocked_amount || claimDetails.ioBlockedAmount || claimDetails.io_blocked_amount,
remainingBalance: internalOrder.ioRemainingBalance || internalOrder.io_remaining_balance || claimDetails.ioRemainingBalance || claimDetails.io_remaining_balance,
organizedBy: internalOrder.organizer?.displayName || internalOrder.organizer?.name || internalOrder.organizedBy || '',
organizedAt: internalOrder.organizedAt || internalOrder.organized_at || '',
};
// Map DMS details from new invoice and credit note tables
const dmsDetails = {
eInvoiceNumber: invoice.invoiceNumber || invoice.invoice_number ||
claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
eInvoiceDate: invoice.invoiceDate || invoice.invoice_date ||
claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
dmsNumber: invoice.dmsNumber || invoice.dms_number ||
claimDetails.dmsNumber || claimDetails.dms_number,
creditNoteNumber: creditNote.creditNoteNumber || creditNote.credit_note_number ||
claimDetails.creditNoteNumber || claimDetails.credit_note_number,
creditNoteDate: creditNote.creditNoteDate || creditNote.credit_note_date ||
claimDetails.creditNoteDate || claimDetails.credit_note_date,
creditNoteAmount: creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(creditNote.credit_note_amount ? Number(creditNote.credit_note_amount) :
(creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(claimDetails.creditNoteAmount ? Number(claimDetails.creditNoteAmount) :
(claimDetails.credit_note_amount ? Number(claimDetails.credit_note_amount) : undefined)))),
};
// Map claim amounts
const claimAmount = {
estimated: activityInfo.estimatedBudget || 0,
closed: activityInfo.closedExpenses || 0,
};
return {
activityInfo,
dealerInfo,
proposalDetails: proposal,
ioDetails: Object.keys(ioDetails).some(k => ioDetails[k as keyof typeof ioDetails]) ? ioDetails : undefined,
dmsDetails: Object.keys(dmsDetails).some(k => dmsDetails[k as keyof typeof dmsDetails]) ? dmsDetails : undefined,
claimAmount,
};
} catch (error) {
console.error('[claimDataMapper] Error mapping claim data:', error);
return null;
}
}
/**
* Determine user's role in the request
*/
export function determineUserRole(apiRequest: any, currentUserId: string): RequestRole {
try {
// Check if user is the initiator
if (apiRequest.initiatorId === currentUserId ||
apiRequest.initiator?.userId === currentUserId ||
apiRequest.requestedBy?.userId === currentUserId) {
return 'INITIATOR';
}
// Check if user is a dealer (participant with DEALER type)
const participants = apiRequest.participants || [];
const dealerParticipant = participants.find((p: any) =>
(p.userId === currentUserId || p.user?.userId === currentUserId) &&
(p.participantType === 'DEALER' || p.type === 'DEALER')
);
if (dealerParticipant) {
return 'DEALER';
}
// Check if user is a department lead (approver at level 3)
const approvalLevels = apiRequest.approvalLevels || [];
const deptLeadLevel = approvalLevels.find((level: any) =>
level.levelNumber === 3 &&
(level.approverId === currentUserId || level.approver?.userId === currentUserId)
);
if (deptLeadLevel) {
return 'DEPARTMENT_LEAD';
}
// Check if user is an approver
const approverLevel = approvalLevels.find((level: any) =>
(level.approverId === currentUserId || level.approver?.userId === currentUserId) &&
level.status === 'PENDING'
);
if (approverLevel) {
return 'APPROVER';
}
// Default to spectator
return 'SPECTATOR';
} catch (error) {
console.error('[claimDataMapper] Error determining user role:', error);
return 'SPECTATOR';
}
}
/**
* Get role-based visibility settings
*/
export function getRoleBasedVisibility(role: RequestRole): RoleVisibility {
switch (role) {
case 'INITIATOR':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false, // Can only edit in specific scenarios
};
case 'DEALER':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: false,
showDMSDetails: false,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'DEPARTMENT_LEAD':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'APPROVER':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'SPECTATOR':
default:
return {
showDealerInfo: false,
showProposalDetails: false,
showIODetails: false,
showDMSDetails: false,
showClaimAmount: false,
canEditClaimAmount: false,
};
}
}

View File

@ -0,0 +1,57 @@
/**
* Utility functions for identifying and handling Claim Management requests
* Works with both old format (templateType: 'claim-management') and new format (workflowType: 'CLAIM_MANAGEMENT')
*/
/**
* Check if a request is a Claim Management request
* Supports both old and new backend formats
*/
export function isClaimManagementRequest(request: any): boolean {
if (!request) return false;
// New format: Check workflowType
if (request.workflowType === 'CLAIM_MANAGEMENT') {
return true;
}
// Old format: Check templateType (for backward compatibility)
if (request.templateType === 'claim-management' || request.template === 'claim-management') {
return true;
}
// Check template name/code
if (request.templateName === 'Claim Management' || request.templateCode === 'CLAIM_MANAGEMENT') {
return true;
}
return false;
}
/**
* Get workflow type from request
* Returns 'CLAIM_MANAGEMENT' for claim requests, 'NON_TEMPLATIZED' for others
*/
export function getWorkflowType(request: any): string {
if (!request) return 'NON_TEMPLATIZED';
// New format
if (request.workflowType) {
return request.workflowType;
}
// Old format: Map templateType to workflowType
if (request.templateType === 'claim-management' || request.template === 'claim-management') {
return 'CLAIM_MANAGEMENT';
}
return 'NON_TEMPLATIZED';
}
/**
* Check if request needs claim-specific UI components
*/
export function shouldUseClaimManagementUI(request: any): boolean {
return isClaimManagementRequest(request);
}

View File

@ -0,0 +1,79 @@
/**
* Global Request Navigation Utility
*
* Centralized navigation logic for request-related routes.
* This utility decides where to navigate when clicking on request cards
* from anywhere in the application.
*
* Features:
* - Single point of navigation logic
* - Handles draft vs active requests
* - Supports different flow types (CUSTOM, DEALER_CLAIM)
* - Type-safe navigation
*/
import { NavigateFunction } from 'react-router-dom';
import { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';
export interface RequestNavigationOptions {
requestId: string;
requestTitle?: string;
status?: string;
request?: any; // Full request object if available
navigate: NavigateFunction;
}
/**
* Navigate to the appropriate request detail page based on request type
*
* This is the single point of navigation for all request cards.
* It handles:
* - Draft requests (navigate to edit)
* - Different flow types (CUSTOM, DEALER_CLAIM)
* - Status-based routing
*/
export function navigateToRequest(options: RequestNavigationOptions): void {
const { requestId, status, request, navigate } = options;
// Check if request is a draft - if so, route to edit form instead of detail view
const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';
if (isDraft) {
navigate(`/edit-request/${requestId}`);
return;
}
// Determine the appropriate route based on request type
const route = getRequestDetailRoute(requestId, request);
navigate(route);
}
/**
* Navigate to create a new request based on flow type
*/
export function navigateToCreateRequest(
navigate: NavigateFunction,
flowType: RequestFlowType = 'CUSTOM'
): void {
const route = flowType === 'DEALER_CLAIM'
? '/claim-management'
: '/new-request';
navigate(route);
}
/**
* Create a navigation handler function for request cards
* This can be used directly in onClick handlers
*/
export function createRequestNavigationHandler(
navigate: NavigateFunction
) {
return (requestId: string, requestTitle?: string, status?: string, request?: any) => {
navigateToRequest({
requestId,
requestTitle,
status,
request,
navigate,
});
};
}

View File

@ -0,0 +1,109 @@
/**
* Request Type Detection and Utilities
*
* Centralized utility for identifying request types and determining
* which flow/components to use for each request type.
*
* Supported Types:
* - CUSTOM: Standard custom requests
* - DEALER_CLAIM: Dealer claim management requests
*/
export type RequestFlowType = 'CUSTOM' | 'DEALER_CLAIM';
/**
* Check if a request is a Dealer Claim request
* Supports both old and new backend formats
*/
export function isDealerClaimRequest(request: any): boolean {
if (!request) return false;
// New format: Check workflowType
if (request.workflowType === 'CLAIM_MANAGEMENT' || request.workflowType === 'DEALER_CLAIM') {
return true;
}
// Old format: Check templateType (for backward compatibility)
if (request.templateType === 'claim-management' ||
request.template === 'claim-management' ||
request.templateType === 'dealer-claim') {
return true;
}
// Check template name/code
if (request.templateName === 'Claim Management' ||
request.templateCode === 'CLAIM_MANAGEMENT' ||
request.templateCode === 'DEALER_CLAIM') {
return true;
}
return false;
}
/**
* Check if a request is a Custom request
*/
export function isCustomRequest(request: any): boolean {
if (!request) return false;
// If it's explicitly marked as custom
if (request.workflowType === 'CUSTOM' ||
request.workflowType === 'NON_TEMPLATIZED' ||
request.templateType === 'custom' ||
request.template === 'custom') {
return true;
}
// If it's not a dealer claim, assume it's custom
// This handles legacy requests that don't have explicit type
if (!isDealerClaimRequest(request)) {
return true;
}
return false;
}
/**
* Get the flow type for a request
* Returns the appropriate RequestFlowType based on request properties
*/
export function getRequestFlowType(request: any): RequestFlowType {
if (isDealerClaimRequest(request)) {
return 'DEALER_CLAIM';
}
return 'CUSTOM';
}
/**
* Get the route path for a request detail page based on flow type
*/
export function getRequestDetailRoute(requestId: string, _request?: any): string {
// For now, all requests use the same route
// In the future, you can customize routes per flow type:
// if (flowType === 'DEALER_CLAIM') {
// return `/dealer-claim/${requestId}`;
// }
// return `/custom-request/${requestId}`;
return `/request/${requestId}`;
}
/**
* Get the route path for creating a new request based on flow type
*/
export function getCreateRequestRoute(flowType: RequestFlowType): string {
switch (flowType) {
case 'DEALER_CLAIM':
return '/claim-management';
case 'CUSTOM':
default:
return '/new-request';
}
}
/**
* Check if request needs flow-specific UI components
*/
export function shouldUseFlowSpecificUI(request: any, flowType: RequestFlowType): boolean {
return getRequestFlowType(request) === flowType;
}