Compare commits
No commits in common. "ca3f6f33d1b39ff00a068f63728ed2032f99bab7" and "b9a8c5bf523521d0b5be4fe9a347a6dc343d7e5b" have entirely different histories.
ca3f6f33d1
...
b9a8c5bf52
@ -1,234 +0,0 @@
|
||||
# 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.
|
||||
@ -1,194 +0,0 @@
|
||||
# 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.
|
||||
@ -1,220 +0,0 @@
|
||||
# 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!
|
||||
@ -1,174 +0,0 @@
|
||||
# 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.
|
||||
@ -1,257 +0,0 @@
|
||||
# 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.
|
||||
@ -1,220 +0,0 @@
|
||||
# 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!
|
||||
|
||||
@ -1,222 +0,0 @@
|
||||
# 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.
|
||||
@ -1,231 +0,0 @@
|
||||
# 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
|
||||
|
||||
162
src/App.tsx
162
src/App.tsx
@ -9,7 +9,7 @@ import { SharedSummaries } from '@/pages/SharedSummaries/SharedSummaries';
|
||||
import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail';
|
||||
import { WorkNotes } from '@/pages/WorkNotes';
|
||||
import { CreateRequest } from '@/pages/CreateRequest';
|
||||
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
||||
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard';
|
||||
import { MyRequests } from '@/pages/MyRequests';
|
||||
import { Requests } from '@/pages/Requests/Requests';
|
||||
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
||||
@ -24,9 +24,15 @@ import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { toast } from 'sonner';
|
||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
||||
import { createClaimRequest } from '@/services/dealerClaimApi';
|
||||
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
||||
|
||||
// Combined Request Database for backward compatibility
|
||||
// This combines both custom and claim management requests
|
||||
export const REQUEST_DATABASE: any = {
|
||||
...CUSTOM_REQUEST_DATABASE,
|
||||
...CLAIM_MANAGEMENT_DATABASE
|
||||
};
|
||||
|
||||
interface AppProps {
|
||||
onLogout?: () => void;
|
||||
@ -55,20 +61,6 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
|
||||
const [selectedRequestId, setSelectedRequestId] = 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
|
||||
useEffect(() => {
|
||||
@ -109,19 +101,17 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string, request?: any) => {
|
||||
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => {
|
||||
setSelectedRequestId(requestId);
|
||||
setSelectedRequestTitle(requestTitle || 'Unknown Request');
|
||||
|
||||
// Use global navigation utility for consistent routing
|
||||
const { navigateToRequest } = await import('@/utils/requestNavigation');
|
||||
navigateToRequest({
|
||||
requestId,
|
||||
requestTitle,
|
||||
status,
|
||||
request,
|
||||
navigate,
|
||||
});
|
||||
// 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}`);
|
||||
} else {
|
||||
navigate(`/request/${requestId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
@ -275,101 +265,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
setApprovalAction(null);
|
||||
};
|
||||
|
||||
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
|
||||
/*
|
||||
const handleClaimManagementSubmit = (claimData: any) => {
|
||||
// Generate unique ID for the new claim request
|
||||
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
|
||||
|
||||
@ -561,7 +457,6 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
description: 'Your claim management request has been created successfully.',
|
||||
});
|
||||
navigate('/my-requests');
|
||||
*/
|
||||
};
|
||||
|
||||
return (
|
||||
@ -779,27 +674,6 @@ 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 */}
|
||||
{approvalAction && (
|
||||
<ApprovalActionModal
|
||||
|
||||
@ -1,163 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,12 +27,8 @@ export function SLAProgressBar({
|
||||
isPaused = false,
|
||||
testId = 'sla-progress'
|
||||
}: SLAProgressBarProps) {
|
||||
// Pure presentational component - no business logic
|
||||
// If request is closed/approved/rejected or no SLA data, show status message
|
||||
// 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') {
|
||||
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
||||
return (
|
||||
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
|
||||
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
|
||||
@ -51,7 +47,7 @@ export function SLAProgressBar({
|
||||
// Use percentage-based colors to match approver SLA tracker
|
||||
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
||||
// Grey: When paused (frozen state)
|
||||
const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : 0;
|
||||
const percentageUsed = sla.percentageUsed || 0;
|
||||
const rawStatus = sla.status || 'on_track';
|
||||
|
||||
// Determine colors based on percentage (matching ApprovalStepCard logic)
|
||||
@ -121,12 +117,12 @@ export function SLAProgressBar({
|
||||
className={`text-xs ${colors.badge}`}
|
||||
data-testid={`${testId}-badge`}
|
||||
>
|
||||
{percentageUsed}% elapsed {isPaused && '(frozen)'}
|
||||
{sla.percentageUsed || 0}% elapsed {isPaused && '(frozen)'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={percentageUsed}
|
||||
value={sla.percentageUsed || 0}
|
||||
className="h-3 mb-2"
|
||||
indicatorClassName={colors.progress}
|
||||
data-testid={`${testId}-bar`}
|
||||
@ -134,7 +130,7 @@ export function SLAProgressBar({
|
||||
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-gray-600" data-testid={`${testId}-elapsed`}>
|
||||
{formatHoursMinutes(sla.elapsedHours || 0)} elapsed
|
||||
{sla.elapsedText || formatHoursMinutes(sla.elapsedHours || 0)} elapsed
|
||||
</span>
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
@ -150,7 +146,7 @@ export function SLAProgressBar({
|
||||
|
||||
{sla.deadline && (
|
||||
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
|
||||
Due: {formatDateDDMMYYYY(sla.deadline, true)} • {percentageUsed}% elapsed
|
||||
Due: {formatDateDDMMYYYY(sla.deadline, true)} • {sla.percentageUsed || 0}% elapsed
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@ -0,0 +1,794 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
1
src/components/workflow/ClaimManagementDetail/index.ts
Normal file
1
src/components/workflow/ClaimManagementDetail/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ClaimManagementDetail } from './ClaimManagementDetail';
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -25,7 +25,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
||||
import { getAllDealers, getDealerInfo, formatDealerAddress } from '@/utils/dealerDatabase';
|
||||
|
||||
interface ClaimManagementWizardProps {
|
||||
onBack?: () => void;
|
||||
@ -41,6 +41,9 @@ const CLAIM_TYPES = [
|
||||
'Service Campaign'
|
||||
];
|
||||
|
||||
// Fetch dealers from database
|
||||
const DEALERS = getAllDealers();
|
||||
|
||||
const STEP_NAMES = [
|
||||
'Claim Details',
|
||||
'Review & Submit'
|
||||
@ -48,8 +51,6 @@ const STEP_NAMES = [
|
||||
|
||||
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [dealers, setDealers] = useState<DealerInfo[]>([]);
|
||||
const [loadingDealers, setLoadingDealers] = useState(true);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
activityName: '',
|
||||
@ -69,23 +70,6 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
|
||||
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) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
@ -119,26 +103,14 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
}
|
||||
};
|
||||
|
||||
const handleDealerChange = async (dealerCode: string) => {
|
||||
const selectedDealer = dealers.find(d => d.dealerCode === dealerCode);
|
||||
if (selectedDealer) {
|
||||
updateFormData('dealerCode', dealerCode);
|
||||
updateFormData('dealerName', selectedDealer.dealerName);
|
||||
updateFormData('dealerEmail', selectedDealer.email || '');
|
||||
updateFormData('dealerPhone', selectedDealer.phone || '');
|
||||
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);
|
||||
}
|
||||
const handleDealerChange = (dealerCode: string) => {
|
||||
const dealer = getDealerInfo(dealerCode);
|
||||
if (dealer) {
|
||||
updateFormData('dealerCode', dealer.code);
|
||||
updateFormData('dealerName', dealer.name);
|
||||
updateFormData('dealerEmail', dealer.email);
|
||||
updateFormData('dealerPhone', dealer.phone);
|
||||
updateFormData('dealerAddress', formatDealerAddress(dealer));
|
||||
}
|
||||
};
|
||||
|
||||
@ -202,7 +174,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
]
|
||||
};
|
||||
|
||||
// Don't show toast here - let the parent component handle success/error after API call
|
||||
toast.success('Claim Request Created', {
|
||||
description: 'Your claim management request has been submitted successfully.'
|
||||
});
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(claimData);
|
||||
}
|
||||
@ -245,7 +220,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
<div>
|
||||
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
|
||||
<Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}>
|
||||
<SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="activityType">
|
||||
<SelectTrigger className="mt-2 h-12">
|
||||
<SelectValue placeholder="Select activity type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -259,10 +234,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
|
||||
{/* Dealer Selection */}
|
||||
<div>
|
||||
<Label className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
|
||||
<Select value={formData.dealerCode} onValueChange={handleDealerChange} disabled={loadingDealers}>
|
||||
<SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="dealer-select">
|
||||
<SelectValue placeholder={loadingDealers ? "Loading dealers..." : "Select dealer"}>
|
||||
<Label htmlFor="dealer" className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
|
||||
<Select value={formData.dealerCode} onValueChange={handleDealerChange}>
|
||||
<SelectTrigger className="mt-2 h-12">
|
||||
<SelectValue placeholder="Select dealer">
|
||||
{formData.dealerCode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{formData.dealerCode}</span>
|
||||
@ -273,19 +248,15 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dealers.length === 0 && !loadingDealers ? (
|
||||
<div className="p-2 text-sm text-gray-500">No dealers available</div>
|
||||
) : (
|
||||
dealers.map((dealer) => (
|
||||
<SelectItem key={dealer.userId} value={dealer.dealerCode}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">{dealer.dealerCode}</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>{dealer.dealerName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
{DEALERS.map((dealer) => (
|
||||
<SelectItem key={dealer.code} value={dealer.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">{dealer.code}</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>{dealer.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.dealerCode && (
|
||||
1
src/components/workflow/ClaimManagementWizard/index.ts
Normal file
1
src/components/workflow/ClaimManagementWizard/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ClaimManagementWizard } from './ClaimManagementWizard';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,27 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,644 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,393 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,311 +0,0 @@
|
||||
/**
|
||||
* 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
@ -1,202 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,291 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,159 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,293 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,376 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,733 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,536 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,359 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,194 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,442 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,730 +0,0 @@
|
||||
/**
|
||||
* 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
93
src/flows.ts
@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
||||
import apiClient from '@/services/authApi';
|
||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||
import { getSocket } from '@/utils/socket';
|
||||
@ -230,87 +229,6 @@ export function useRequestDetails(
|
||||
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
|
||||
* This object is used throughout the UI
|
||||
@ -324,16 +242,12 @@ export function useRequestDetails(
|
||||
description: wf.description,
|
||||
status: statusMap(wf.status),
|
||||
priority: (wf.priority || '').toString().toLowerCase(),
|
||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
||||
approvalFlow,
|
||||
approvals, // Raw approvals for SLA calculations
|
||||
participants,
|
||||
documents: mappedDocuments,
|
||||
spectators,
|
||||
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: {
|
||||
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||
role: wf.initiator?.designation || undefined,
|
||||
@ -352,16 +266,6 @@ export function useRequestDetails(
|
||||
conclusionRemark: wf.conclusionRemark || null,
|
||||
closureDate: wf.closureDate || null,
|
||||
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);
|
||||
@ -537,66 +441,6 @@ export function useRequestDetails(
|
||||
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
|
||||
const mapped = {
|
||||
id: wf.requestNumber || wf.requestId,
|
||||
@ -605,7 +449,6 @@ export function useRequestDetails(
|
||||
description: wf.description,
|
||||
priority,
|
||||
status: statusMap(wf.status),
|
||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
||||
summary,
|
||||
initiator: {
|
||||
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||
@ -629,16 +472,6 @@ export function useRequestDetails(
|
||||
conclusionRemark: wf.conclusionRemark || null,
|
||||
closureDate: wf.closureDate || 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);
|
||||
@ -806,26 +639,35 @@ export function useRequestDetails(
|
||||
|
||||
const socket = getSocket();
|
||||
if (!socket) {
|
||||
console.warn('[useRequestDetails] Socket not available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useRequestDetails] Setting up socket listener for request:', apiRequest.requestId);
|
||||
|
||||
/**
|
||||
* Handler: Request updated by another user
|
||||
* Silently refresh to show latest changes
|
||||
*/
|
||||
const handleRequestUpdated = (data: any) => {
|
||||
console.log('[useRequestDetails] 📡 Received request:updated event:', data);
|
||||
// Verify this update is for the current request
|
||||
if (data?.requestId === apiRequest.requestId || data?.requestNumber === requestIdentifier) {
|
||||
console.log('[useRequestDetails] 🔄 Request updated remotely, refreshing silently...');
|
||||
// Silent refresh - no loading state, no user interruption
|
||||
refreshDetails();
|
||||
} else {
|
||||
console.log('[useRequestDetails] ⚠️ Event for different request, ignoring');
|
||||
}
|
||||
};
|
||||
|
||||
// Register listener
|
||||
socket.on('request:updated', handleRequestUpdated);
|
||||
console.log('[useRequestDetails] ✅ Registered listener for request:updated');
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
console.log('[useRequestDetails] 🧹 Cleaning up socket listener');
|
||||
socket.off('request:updated', handleRequestUpdated);
|
||||
};
|
||||
}, [requestIdentifier, apiRequest, refreshDetails]);
|
||||
|
||||
@ -68,16 +68,7 @@ export function ApproverPerformanceRequestList({
|
||||
<Card
|
||||
key={request.requestId}
|
||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => {
|
||||
const { navigateToRequest } = require('@/utils/requestNavigation');
|
||||
navigateToRequest({
|
||||
requestId: request.requestId,
|
||||
requestTitle: request.title,
|
||||
status: request.status,
|
||||
request: request,
|
||||
navigate,
|
||||
});
|
||||
}}
|
||||
onClick={() => navigate(`/request/${request.requestId}`)}
|
||||
data-testid={`request-card-${request.requestId}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
@ -166,14 +157,7 @@ export function ApproverPerformanceRequestList({
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const { navigateToRequest } = require('@/utils/requestNavigation');
|
||||
navigateToRequest({
|
||||
requestId: request.requestId,
|
||||
requestTitle: request.title,
|
||||
status: request.status,
|
||||
request: request,
|
||||
navigate,
|
||||
});
|
||||
navigate(`/request/${request.requestId}`);
|
||||
}}
|
||||
data-testid="view-request-button"
|
||||
>
|
||||
|
||||
@ -61,32 +61,6 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
|
||||
>
|
||||
{request.priority}
|
||||
</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>
|
||||
|
||||
{/* Title */}
|
||||
|
||||
@ -69,11 +69,7 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
}, [onBack, navigate]);
|
||||
|
||||
const handleViewRequest = useCallback((requestId: string) => {
|
||||
const { navigateToRequest } = require('@/utils/requestNavigation');
|
||||
navigateToRequest({
|
||||
requestId,
|
||||
navigate,
|
||||
});
|
||||
navigate(`/request/${requestId}`);
|
||||
}, [navigate]);
|
||||
|
||||
// Export handlers
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowRight, User, TrendingUp, Clock, Pause } from 'lucide-react';
|
||||
import { ArrowRight, User, TrendingUp, Clock, FileText, Pause } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MyRequest } from '../types/myRequests.types';
|
||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
@ -97,32 +97,16 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
<PriorityIcon className="w-3 h-3 mr-1" />
|
||||
{request.priority}
|
||||
</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.templateType && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-purple-100 text-purple-700 text-xs shrink-0 hidden sm:inline-flex"
|
||||
data-testid="template-badge"
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Template: {request.templateName}
|
||||
</Badge>
|
||||
)}
|
||||
</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">
|
||||
{stripHtmlTags(request.description || '') || 'No description provided'}
|
||||
|
||||
@ -16,7 +16,6 @@ export interface MyRequest {
|
||||
currentApprover?: string;
|
||||
approverLevel?: string;
|
||||
templateType?: string;
|
||||
workflowType?: string;
|
||||
templateName?: string;
|
||||
pauseInfo?: {
|
||||
isPaused: boolean;
|
||||
|
||||
@ -29,9 +29,8 @@ export function transformRequest(req: any): MyRequest {
|
||||
: req.currentStep && req.totalSteps
|
||||
? `${req.currentStep} of ${req.totalSteps}`
|
||||
: '—',
|
||||
templateType: req.templateType || req.template_type,
|
||||
workflowType: req.workflowType || req.workflow_type,
|
||||
templateName: req.templateName || req.template_name,
|
||||
templateType: req.templateType,
|
||||
templateName: req.templateName,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -28,7 +28,6 @@ interface Request {
|
||||
currentLevelSLA?: any; // Backend-provided SLA for current level
|
||||
isPaused?: boolean; // Pause status
|
||||
pauseInfo?: any; // Pause details
|
||||
templateType?: string; // Template type for badge display
|
||||
}
|
||||
|
||||
interface OpenRequestsProps {
|
||||
@ -179,7 +178,6 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
approvalStep: r.currentLevel ? `Step ${r.currentLevel} of ${r.totalLevels || '?'}` : undefined,
|
||||
department: r.department,
|
||||
currentLevelSLA: r.currentLevelSLA, // ← Backend-calculated current level SLA
|
||||
templateType: r.templateType || r.template_type, // ← Template type for badge display
|
||||
};
|
||||
});
|
||||
setItems(mapped);
|
||||
@ -460,32 +458,6 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
>
|
||||
{request.priority}
|
||||
</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>
|
||||
|
||||
{/* Title */}
|
||||
|
||||
@ -1,26 +1,60 @@
|
||||
/**
|
||||
* RequestDetail Router Component
|
||||
/**
|
||||
* RequestDetail Component
|
||||
*
|
||||
* Purpose: Routes to the appropriate flow-specific RequestDetail screen
|
||||
* Purpose: Display and manage detailed view of a workflow request
|
||||
*
|
||||
* Architecture:
|
||||
* - This is a router that determines the flow type and renders the appropriate screen
|
||||
* - Each flow has its own complete RequestDetail screen in its folder
|
||||
* - Deleting a flow folder removes all related code (truly modular)
|
||||
*
|
||||
* IMPORTANT: This file only routes. All actual implementation is in flow-specific folders.
|
||||
* Deleting src/custom/ or src/dealer-claim/ removes ALL related code.
|
||||
* - Uses custom hooks for complex logic (data fetching, socket, document upload, etc.)
|
||||
* - Delegates UI rendering to specialized tab components
|
||||
* - Error boundary for graceful error handling
|
||||
* - Real-time WebSocket integration
|
||||
*/
|
||||
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { useRequestDetails } from '@/hooks/useRequestDetails';
|
||||
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 { getRequestFlowType } from '@/utils/requestTypeUtils';
|
||||
import { getRequestDetailScreen } from '@/flows';
|
||||
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';
|
||||
|
||||
// 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 { PauseModal } from '@/components/workflow/PauseModal';
|
||||
import { ResumeModal } from '@/components/workflow/ResumeModal';
|
||||
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
|
||||
|
||||
/**
|
||||
* Error Boundary Component
|
||||
@ -36,7 +70,7 @@ class RequestDetailErrorBoundary extends Component<{ children: ReactNode }, { ha
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('RequestDetail Router Error:', error, errorInfo);
|
||||
console.error('RequestDetail Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
override render() {
|
||||
@ -62,24 +96,209 @@ class RequestDetailErrorBoundary extends Component<{ children: ReactNode }, { ha
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* RequestDetailInner Component
|
||||
*/
|
||||
function RequestDetailRouter({ requestId: propRequestId, onBack, dynamicRequests = [] }: RequestDetailProps) {
|
||||
function RequestDetailInner({ 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();
|
||||
|
||||
// Fetch request details to determine flow type
|
||||
// Custom hooks
|
||||
const {
|
||||
request,
|
||||
apiRequest,
|
||||
loading: requestLoading,
|
||||
refreshing,
|
||||
refreshDetails,
|
||||
currentApprovalLevel,
|
||||
isSpectator,
|
||||
isInitiator,
|
||||
existingParticipants,
|
||||
accessDenied,
|
||||
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
|
||||
|
||||
// Loading state while determining flow type
|
||||
if (requestLoading && !apiRequest) {
|
||||
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 () => {
|
||||
// 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 (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state">
|
||||
<div className="text-center">
|
||||
@ -90,36 +309,359 @@ function RequestDetailRouter({ requestId: propRequestId, onBack, dynamicRequests
|
||||
);
|
||||
}
|
||||
|
||||
// Determine flow type and get the appropriate RequestDetail screen
|
||||
const flowType = getRequestFlowType(apiRequest);
|
||||
const RequestDetailScreen = getRequestDetailScreen(flowType);
|
||||
// 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="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 (
|
||||
<RequestDetailScreen
|
||||
requestId={propRequestId}
|
||||
onBack={onBack}
|
||||
dynamicRequests={dynamicRequests}
|
||||
/>
|
||||
<>
|
||||
<div className="min-h-screen bg-gray-50" data-testid="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}
|
||||
/>
|
||||
|
||||
{/* 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)
|
||||
*
|
||||
* 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) {
|
||||
return (
|
||||
<RequestDetailErrorBoundary>
|
||||
<RequestDetailRouter {...props} />
|
||||
<RequestDetailInner {...props} />
|
||||
</RequestDetailErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* Quick Actions Sidebar Component
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
@ -10,9 +10,6 @@ import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle }
|
||||
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
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 {
|
||||
request: any;
|
||||
@ -30,8 +27,6 @@ interface QuickActionsSidebarProps {
|
||||
refreshTrigger?: number; // Trigger to refresh shared recipients list
|
||||
pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility)
|
||||
currentUserId?: string; // Current user's ID (kept for backwards compatibility)
|
||||
apiRequest?: any;
|
||||
onEditClaimAmount?: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionsSidebar({
|
||||
@ -48,10 +43,6 @@ export function QuickActionsSidebar({
|
||||
onRetrigger,
|
||||
summaryId,
|
||||
refreshTrigger,
|
||||
pausedByUserId: pausedByUserIdProp,
|
||||
currentUserId: currentUserIdProp,
|
||||
apiRequest,
|
||||
onEditClaimAmount,
|
||||
}: QuickActionsSidebarProps) {
|
||||
const { user } = useAuth();
|
||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||
@ -59,8 +50,8 @@ export function QuickActionsSidebar({
|
||||
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
|
||||
const isClosed = request?.status === 'closed';
|
||||
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||
const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
|
||||
const currentUserId = currentUserIdProp || (user as any)?.userId || '';
|
||||
const pausedByUserId = request?.pauseInfo?.pausedBy?.userId;
|
||||
const currentUserId = (user as any)?.userId || '';
|
||||
|
||||
// Both approver AND initiator can pause (when not already paused and not closed)
|
||||
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
|
||||
@ -126,16 +117,6 @@ export function QuickActionsSidebar({
|
||||
fetchSharedRecipients();
|
||||
}, [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 (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Quick Actions Card - Hide entire card for spectators and closed requests */}
|
||||
@ -358,21 +339,6 @@ export function QuickActionsSidebar({
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,23 +15,12 @@ interface RequestDetailHeaderProps {
|
||||
onRefresh: () => void;
|
||||
onShareSummary?: () => void;
|
||||
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,
|
||||
slaData, // Module passes prepared SLA data
|
||||
isPaused = false // Module passes pause status
|
||||
}: RequestDetailHeaderProps) {
|
||||
export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, onShareSummary, isInitiator }: RequestDetailHeaderProps) {
|
||||
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
|
||||
const statusConfig = getStatusConfig(request?.status || 'pending');
|
||||
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
|
||||
@ -61,7 +50,7 @@ export function RequestDetailHeader({
|
||||
<h1 className="text-sm sm:text-base md:text-lg font-bold text-gray-900 truncate" data-testid="request-id">
|
||||
{request.id || 'N/A'}
|
||||
</h1>
|
||||
{/* Priority, Status, and Template Type Badges */}
|
||||
{/* Priority and Status Badges */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||
<Badge
|
||||
className={`${priorityConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`}
|
||||
@ -77,26 +66,6 @@ export function RequestDetailHeader({
|
||||
>
|
||||
{statusConfig.label}
|
||||
</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>
|
||||
@ -140,19 +109,17 @@ export function RequestDetailHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLA Progress Section - Plug-and-play: Module passes prepared SLA data */}
|
||||
{slaData !== undefined && (
|
||||
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${
|
||||
isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
|
||||
}`} data-testid="sla-section">
|
||||
<SLAProgressBar
|
||||
sla={slaData}
|
||||
requestStatus={request.status}
|
||||
isPaused={isPaused}
|
||||
testId="request-sla"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* SLA Progress Section */}
|
||||
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${
|
||||
isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
|
||||
}`} data-testid="sla-section">
|
||||
<SLAProgressBar
|
||||
sla={request.summary?.sla || request.sla}
|
||||
requestStatus={request.status}
|
||||
isPaused={isPaused}
|
||||
testId="request-sla"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@ -96,32 +96,6 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
<PriorityIcon className="w-3 h-3 mr-1" />
|
||||
{request.priority}
|
||||
</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 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
@ -62,7 +62,6 @@ export interface ConvertedRequest {
|
||||
currentApprover: string;
|
||||
approverLevel: string;
|
||||
templateType?: string;
|
||||
workflowType?: string;
|
||||
templateName?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -97,9 +97,8 @@ export function transformRequest(req: any): ConvertedRequest {
|
||||
createdAt: createdAt,
|
||||
currentApprover: currentApprover,
|
||||
approverLevel: approverLevel,
|
||||
templateType: req.templateType || req.template_type,
|
||||
workflowType: req.workflowType || req.workflow_type,
|
||||
templateName: req.templateName || req.template_name
|
||||
templateType: req.templateType,
|
||||
templateName: req.templateName
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,306 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,7 +275,7 @@ export async function listClosedByMe(params: { page?: number; limit?: number; se
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWorkflowDetails(requestId: string, _workflowType?: string) {
|
||||
export async function getWorkflowDetails(requestId: string) {
|
||||
const res = await apiClient.get(`/workflows/${requestId}/details`);
|
||||
return res.data?.data || res.data;
|
||||
}
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -1,387 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -1,109 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user