Compare commits
No commits in common. "d285ea88d855509f4794cba378df82217c7db719" and "1b4091c3d3eaad215cad9aebf37cdc5e299e5cff" have entirely different histories.
d285ea88d8
...
1b4091c3d3
@ -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
|
|
||||||
|
|
||||||
70
index.html
70
index.html
@ -1,23 +1,61 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
||||||
|
<meta name="theme-color" content="#2d4a3e" />
|
||||||
|
<title>Royal Enfield | Approval Portal</title>
|
||||||
|
|
||||||
<head>
|
<!-- Preload critical fonts and icons -->
|
||||||
<meta charset="UTF-8" />
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description"
|
|
||||||
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
|
||||||
<meta name="theme-color" content="#2d4a3e" />
|
|
||||||
<title>Royal Enfield | Approval Portal</title>
|
|
||||||
|
|
||||||
<!-- Preload critical fonts and icons -->
|
<!-- Ensure proper icon rendering and layout -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<style>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
/* Ensure Lucide icons render properly */
|
||||||
</head>
|
svg {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
<body>
|
/* Fix for icon alignment in buttons */
|
||||||
<div id="root"></div>
|
button svg {
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
flex-shrink: 0;
|
||||||
</body>
|
}
|
||||||
|
|
||||||
|
/* Ensure proper text rendering */
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for mobile viewport and sidebar */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper sidebar toggle behavior */
|
||||||
|
.sidebar-toggle {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for icon button hover states */
|
||||||
|
button:hover svg {
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
248
src/App.tsx
248
src/App.tsx
@ -9,8 +9,7 @@ import { SharedSummaries } from '@/pages/SharedSummaries/SharedSummaries';
|
|||||||
import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail';
|
import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail';
|
||||||
import { WorkNotes } from '@/pages/WorkNotes';
|
import { WorkNotes } from '@/pages/WorkNotes';
|
||||||
import { CreateRequest } from '@/pages/CreateRequest';
|
import { CreateRequest } from '@/pages/CreateRequest';
|
||||||
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard';
|
||||||
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
|
|
||||||
import { MyRequests } from '@/pages/MyRequests';
|
import { MyRequests } from '@/pages/MyRequests';
|
||||||
import { Requests } from '@/pages/Requests/Requests';
|
import { Requests } from '@/pages/Requests/Requests';
|
||||||
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
||||||
@ -28,11 +27,15 @@ import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
|||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||||
|
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||||
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
||||||
import { createClaimRequest } from '@/services/dealerClaimApi';
|
|
||||||
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
// Combined Request Database for backward compatibility
|
||||||
import { navigateToRequest } from '@/utils/requestNavigation';
|
// This combines both custom and claim management requests
|
||||||
// import { TokenManager } from '@/utils/tokenManager';
|
export const REQUEST_DATABASE: any = {
|
||||||
|
...CUSTOM_REQUEST_DATABASE,
|
||||||
|
...CLAIM_MANAGEMENT_DATABASE
|
||||||
|
};
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@ -54,43 +57,6 @@ function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component to conditionally render Dashboard or DealerDashboard based on user job title
|
|
||||||
function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: string) => void; onNewRequest?: () => void }) {
|
|
||||||
const [isDealer, setIsDealer] = useState<boolean>(false);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
// const userData = TokenManager.getUserData();
|
|
||||||
// // setIsDealer(userData?.jobTitle === 'Dealer');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[App] Error checking dealer status:', error);
|
|
||||||
setIsDealer(false);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="w-8 h-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
|
||||||
<p className="text-muted-foreground">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render dealer-specific dashboard if user is a dealer
|
|
||||||
if (isDealer) {
|
|
||||||
return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render regular dashboard for all other users
|
|
||||||
return <Dashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main Application Routes Component
|
// Main Application Routes Component
|
||||||
function AppRoutes({ onLogout }: AppProps) {
|
function AppRoutes({ onLogout }: AppProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -98,20 +64,6 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
|
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
|
||||||
const [selectedRequestId, setSelectedRequestId] = useState<string>('');
|
const [selectedRequestId, setSelectedRequestId] = useState<string>('');
|
||||||
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
|
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
|
||||||
const [managerModalOpen, setManagerModalOpen] = useState(false);
|
|
||||||
const [managerModalData, setManagerModalData] = useState<{
|
|
||||||
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
|
|
||||||
managers?: Array<{
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
displayName: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
department?: string;
|
|
||||||
}>;
|
|
||||||
message?: string;
|
|
||||||
pendingClaimData?: any;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Retrieve dynamic requests from localStorage on mount
|
// Retrieve dynamic requests from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -152,18 +104,17 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewRequest = (requestId: string, requestTitle?: string, status?: string, request?: any) => {
|
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => {
|
||||||
setSelectedRequestId(requestId);
|
setSelectedRequestId(requestId);
|
||||||
setSelectedRequestTitle(requestTitle || 'Unknown Request');
|
setSelectedRequestTitle(requestTitle || 'Unknown Request');
|
||||||
|
|
||||||
// Use global navigation utility for consistent routing
|
// Check if request is a draft - if so, route to edit form instead of detail view
|
||||||
navigateToRequest({
|
const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';
|
||||||
requestId,
|
if (isDraft) {
|
||||||
requestTitle,
|
navigate(`/edit-request/${requestId}`);
|
||||||
status,
|
} else {
|
||||||
request,
|
navigate(`/request/${requestId}`);
|
||||||
navigate,
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@ -184,14 +135,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If requestData has backendId, it means it came from the API flow (CreateRequest component)
|
// Regular custom request submission
|
||||||
// The hook already shows the toast, so we just navigate
|
|
||||||
if (requestData.backendId) {
|
|
||||||
navigate('/my-requests');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular custom request submission (old flow without API)
|
|
||||||
// Generate unique ID for the new custom request
|
// Generate unique ID for the new custom request
|
||||||
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
|
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
|
||||||
|
|
||||||
@ -320,101 +264,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
setApprovalAction(null);
|
setApprovalAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClaimManagementSubmit = async (claimData: any, _selectedManagerEmail?: string) => {
|
const handleClaimManagementSubmit = (claimData: any) => {
|
||||||
try {
|
|
||||||
// Prepare payload for API
|
|
||||||
const payload = {
|
|
||||||
activityName: claimData.activityName,
|
|
||||||
activityType: claimData.activityType,
|
|
||||||
dealerCode: claimData.dealerCode,
|
|
||||||
dealerName: claimData.dealerName,
|
|
||||||
dealerEmail: claimData.dealerEmail || undefined,
|
|
||||||
dealerPhone: claimData.dealerPhone || undefined,
|
|
||||||
dealerAddress: claimData.dealerAddress || undefined,
|
|
||||||
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toISOString() : undefined,
|
|
||||||
location: claimData.location,
|
|
||||||
requestDescription: claimData.requestDescription,
|
|
||||||
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
|
|
||||||
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
|
|
||||||
estimatedBudget: claimData.estimatedBudget || undefined,
|
|
||||||
approvers: claimData.approvers || [], // Pass approvers array
|
|
||||||
};
|
|
||||||
|
|
||||||
// Call API to create claim request
|
|
||||||
const response = await createClaimRequest(payload);
|
|
||||||
|
|
||||||
// Validate response - ensure request was actually created successfully
|
|
||||||
if (!response || !response.request) {
|
|
||||||
throw new Error('Invalid response from server: Request object not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdRequest = response.request;
|
|
||||||
|
|
||||||
// Validate that we have at least one identifier (requestNumber or requestId)
|
|
||||||
if (!createdRequest.requestNumber && !createdRequest.requestId) {
|
|
||||||
throw new Error('Invalid response from server: Request identifier not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close manager modal if open
|
|
||||||
setManagerModalOpen(false);
|
|
||||||
setManagerModalData(null);
|
|
||||||
|
|
||||||
// Only show success toast if request was actually created successfully
|
|
||||||
toast.success('Claim Request Submitted', {
|
|
||||||
description: 'Your claim management request has been created successfully.',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate to the created request detail page using requestNumber
|
|
||||||
if (createdRequest.requestNumber) {
|
|
||||||
navigate(`/request/${createdRequest.requestNumber}`);
|
|
||||||
} else if (createdRequest.requestId) {
|
|
||||||
// Fallback to requestId if requestNumber is not available
|
|
||||||
navigate(`/request/${createdRequest.requestId}`);
|
|
||||||
} else {
|
|
||||||
// This should not happen due to validation above, but just in case
|
|
||||||
navigate('/my-requests');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[App] Error creating claim request:', error);
|
|
||||||
|
|
||||||
// Check for manager-related errors
|
|
||||||
const errorData = error?.response?.data;
|
|
||||||
const errorCode = errorData?.code || errorData?.error?.code;
|
|
||||||
|
|
||||||
if (errorCode === 'NO_MANAGER_FOUND') {
|
|
||||||
// Show modal for no manager found
|
|
||||||
setManagerModalData({
|
|
||||||
errorType: 'NO_MANAGER_FOUND',
|
|
||||||
message: errorData?.message || errorData?.error?.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
|
|
||||||
pendingClaimData: claimData,
|
|
||||||
});
|
|
||||||
setManagerModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
|
|
||||||
// Show modal with manager list for selection
|
|
||||||
const managers = errorData?.managers || errorData?.error?.managers || [];
|
|
||||||
setManagerModalData({
|
|
||||||
errorType: 'MULTIPLE_MANAGERS_FOUND',
|
|
||||||
managers: managers,
|
|
||||||
message: errorData?.message || errorData?.error?.message || 'Multiple managers found. Please select one.',
|
|
||||||
pendingClaimData: claimData,
|
|
||||||
});
|
|
||||||
setManagerModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other errors - show toast
|
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
|
|
||||||
toast.error('Failed to Submit Claim Request', {
|
|
||||||
description: errorMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the old code below for backward compatibility (local storage fallback)
|
|
||||||
// This can be removed once API integration is fully tested
|
|
||||||
/*
|
|
||||||
// Generate unique ID for the new claim request
|
// Generate unique ID for the new claim request
|
||||||
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
|
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
|
||||||
|
|
||||||
@ -606,24 +456,23 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
description: 'Your claim management request has been created successfully.',
|
description: 'Your claim management request has been created successfully.',
|
||||||
});
|
});
|
||||||
navigate('/my-requests');
|
navigate('/my-requests');
|
||||||
*/
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
|
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Auth Callback - Unified callback for both OKTA and Tanflow */}
|
{/* Auth Callback - Must be before other routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/login/callback"
|
path="/login/callback"
|
||||||
element={<AuthCallback />}
|
element={<AuthCallback />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
|
{/* Dashboard */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -632,37 +481,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin Routes Group with Shared Layout */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Outlet />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/admin/create-template" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/templates" element={<AdminTemplatesList />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Create Request from Admin Template (Dedicated Flow) */}
|
|
||||||
<Route
|
|
||||||
path="/create-admin-request/:templateId"
|
|
||||||
element={
|
|
||||||
<CreateAdminRequest />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Admin />
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -878,27 +697,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 */}
|
{/* Approval Action Modal */}
|
||||||
{approvalAction && (
|
{approvalAction && (
|
||||||
<ApprovalActionModal
|
<ApprovalActionModal
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
@ -8,7 +8,6 @@
|
|||||||
// Images
|
// Images
|
||||||
export { default as ReLogo } from './images/Re_Logo.png';
|
export { default as ReLogo } from './images/Re_Logo.png';
|
||||||
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
|
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
|
||||||
export { default as LandingPageImage } from './images/landing_page_image.jpg';
|
|
||||||
|
|
||||||
// Fonts
|
// Fonts
|
||||||
// Add font exports here when fonts are added to the assets/fonts folder
|
// Add font exports here when fonts are added to the assets/fonts folder
|
||||||
|
|||||||
@ -1,451 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Edit2,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
getAllActivityTypes,
|
|
||||||
createActivityType,
|
|
||||||
updateActivityType,
|
|
||||||
deleteActivityType,
|
|
||||||
ActivityType
|
|
||||||
} from '@/services/adminApi';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export function ActivityTypeManager() {
|
|
||||||
const [activityTypes, setActivityTypes] = useState<ActivityType[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
||||||
const [editingActivityType, setEditingActivityType] = useState<ActivityType | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
itemCode: '',
|
|
||||||
taxationType: '',
|
|
||||||
sapRefNo: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadActivityTypes();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadActivityTypes = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await getAllActivityTypes(false); // Get all including inactive
|
|
||||||
setActivityTypes(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg = err.response?.data?.error || 'Failed to load activity types';
|
|
||||||
setError(errorMsg);
|
|
||||||
toast.error(errorMsg);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
setFormData({
|
|
||||||
title: '',
|
|
||||||
itemCode: '',
|
|
||||||
taxationType: '',
|
|
||||||
sapRefNo: ''
|
|
||||||
});
|
|
||||||
setEditingActivityType(null);
|
|
||||||
setShowAddDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (activityType: ActivityType) => {
|
|
||||||
setFormData({
|
|
||||||
title: activityType.title,
|
|
||||||
itemCode: activityType.itemCode || '',
|
|
||||||
taxationType: activityType.taxationType || '',
|
|
||||||
sapRefNo: activityType.sapRefNo || ''
|
|
||||||
});
|
|
||||||
setEditingActivityType(activityType);
|
|
||||||
setShowAddDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!formData.title.trim()) {
|
|
||||||
setError('Activity type title is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Partial<ActivityType> = {
|
|
||||||
title: formData.title.trim(),
|
|
||||||
itemCode: formData.itemCode.trim() || null,
|
|
||||||
taxationType: formData.taxationType.trim() || null,
|
|
||||||
sapRefNo: formData.sapRefNo.trim() || null
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingActivityType) {
|
|
||||||
// Update existing
|
|
||||||
await updateActivityType(editingActivityType.activityTypeId, payload);
|
|
||||||
setSuccessMessage('Activity type updated successfully');
|
|
||||||
toast.success('Activity type updated successfully');
|
|
||||||
} else {
|
|
||||||
// Create new
|
|
||||||
await createActivityType(payload);
|
|
||||||
setSuccessMessage('Activity type created successfully');
|
|
||||||
toast.success('Activity type created successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadActivityTypes();
|
|
||||||
setShowAddDialog(false);
|
|
||||||
setTimeout(() => setSuccessMessage(null), 3000);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg = err.response?.data?.error || 'Failed to save activity type';
|
|
||||||
setError(errorMsg);
|
|
||||||
toast.error(errorMsg);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (activityType: ActivityType) => {
|
|
||||||
if (!confirm(`Delete "${activityType.title}"? This will deactivate the activity type.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await deleteActivityType(activityType.activityTypeId);
|
|
||||||
setSuccessMessage('Activity type deleted successfully');
|
|
||||||
toast.success('Activity type deleted successfully');
|
|
||||||
await loadActivityTypes();
|
|
||||||
setTimeout(() => setSuccessMessage(null), 3000);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg = err.response?.data?.error || 'Failed to delete activity type';
|
|
||||||
setError(errorMsg);
|
|
||||||
toast.error(errorMsg);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter active and inactive activity types
|
|
||||||
const activeActivityTypes = activityTypes.filter(at => at.isActive !== false && at.isActive !== undefined);
|
|
||||||
const inactiveActivityTypes = activityTypes.filter(at => at.isActive === false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Success Message */}
|
|
||||||
{successMessage && (
|
|
||||||
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-300 rounded-md shadow-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
|
||||||
<div className="p-1.5 bg-green-500 rounded-md">
|
|
||||||
<CheckCircle className="w-4 h-4 text-white shrink-0" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-green-900">{successMessage}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-4 bg-gradient-to-r from-red-50 to-rose-50 border border-red-300 rounded-md shadow-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
|
||||||
<div className="p-1.5 bg-red-500 rounded-md">
|
|
||||||
<AlertCircle className="w-4 h-4 text-white shrink-0" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-red-900">{error}</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="ml-auto hover:bg-red-100"
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<Card className="shadow-lg border-0 rounded-md">
|
|
||||||
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
|
|
||||||
<FileText className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-lg sm:text-xl font-semibold text-slate-900">Activity Types</CardTitle>
|
|
||||||
<CardDescription className="text-sm">
|
|
||||||
Manage dealer claim activity types
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleAdd}
|
|
||||||
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span className="hidden xs:inline">Add Activity Type</span>
|
|
||||||
<span className="xs:hidden">Add</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Activity Types List */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
) : activeActivityTypes.length === 0 ? (
|
|
||||||
<Card className="shadow-lg border-0 rounded-md">
|
|
||||||
<CardContent className="p-12 text-center">
|
|
||||||
<div className="p-4 bg-slate-100 rounded-full w-20 h-20 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<FileText className="w-10 h-10 text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-700 font-medium text-lg">No activity types found</p>
|
|
||||||
<p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p>
|
|
||||||
<Button
|
|
||||||
onClick={handleAdd}
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Add First Activity Type
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4 sm:space-y-6">
|
|
||||||
{/* Active Activity Types */}
|
|
||||||
<Card className="shadow-lg border-0 rounded-md">
|
|
||||||
<CardHeader className="pb-3 sm:pb-4 border-b border-slate-100">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">Active Activity Types</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
{activeActivityTypes.length} active type{activeActivityTypes.length !== 1 ? 's' : ''}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 bg-green-50 rounded-md">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 pt-4">
|
|
||||||
{activeActivityTypes.map(activityType => (
|
|
||||||
<div
|
|
||||||
key={activityType.activityTypeId}
|
|
||||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
||||||
<p className="font-semibold text-slate-900 text-sm sm:text-base">{activityType.title}</p>
|
|
||||||
<Badge variant="outline" className="bg-gradient-to-r from-green-50 to-emerald-50 text-green-800 border-green-300 text-[10px] sm:text-xs font-medium shadow-sm">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3 text-xs sm:text-sm text-slate-600">
|
|
||||||
{activityType.itemCode && (
|
|
||||||
<span className="font-medium">Item Code: <span className="text-slate-900">{activityType.itemCode}</span></span>
|
|
||||||
)}
|
|
||||||
{activityType.taxationType && (
|
|
||||||
<span className="font-medium">Taxation: <span className="text-slate-900">{activityType.taxationType}</span></span>
|
|
||||||
)}
|
|
||||||
{activityType.sapRefNo && (
|
|
||||||
<span className="font-medium">SAP Ref: <span className="text-slate-900">{activityType.sapRefNo}</span></span>
|
|
||||||
)}
|
|
||||||
{!activityType.itemCode && !activityType.taxationType && !activityType.sapRefNo && (
|
|
||||||
<span className="text-slate-500 italic">No additional details</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 sm:gap-2 self-end sm:self-auto">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleEdit(activityType)}
|
|
||||||
className="gap-1.5 hover:bg-blue-50 border border-transparent hover:border-blue-200 text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<span className="hidden xs:inline">Edit</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleDelete(activityType)}
|
|
||||||
className="gap-1.5 text-red-600 hover:text-red-700 hover:bg-red-50 border border-transparent hover:border-red-200 text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<span className="hidden xs:inline">Delete</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Inactive Activity Types */}
|
|
||||||
{inactiveActivityTypes.length > 0 && (
|
|
||||||
<Card className="shadow-lg border-0 rounded-md border-amber-200">
|
|
||||||
<CardHeader className="pb-3 sm:pb-4 border-b border-slate-100">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">Inactive Activity Types</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
{inactiveActivityTypes.length} inactive type{inactiveActivityTypes.length !== 1 ? 's' : ''}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 bg-amber-50 rounded-md">
|
|
||||||
<AlertCircle className="w-4 h-4 text-amber-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 pt-4">
|
|
||||||
{inactiveActivityTypes.map(activityType => (
|
|
||||||
<div
|
|
||||||
key={activityType.activityTypeId}
|
|
||||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
||||||
<p className="font-semibold text-slate-700 text-sm sm:text-base line-through">{activityType.title}</p>
|
|
||||||
<Badge variant="outline" className="bg-gradient-to-r from-amber-50 to-orange-50 text-amber-800 border-amber-300 text-[10px] sm:text-xs font-medium shadow-sm">
|
|
||||||
Inactive
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 sm:gap-2 self-end sm:self-auto">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleEdit(activityType)}
|
|
||||||
className="gap-1.5 hover:bg-blue-50 border border-transparent hover:border-blue-200 text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<span className="hidden xs:inline">Edit</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add/Edit Dialog */}
|
|
||||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
|
||||||
<DialogContent className="sm:max-w-[550px] max-h-[90vh] rounded-lg flex flex-col p-0">
|
|
||||||
<DialogHeader className="pb-4 border-b border-slate-100 px-6 pt-6 flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
|
|
||||||
<FileText className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<DialogTitle className="text-xl font-semibold text-slate-900">
|
|
||||||
{editingActivityType ? 'Edit Activity Type' : 'Add New Activity Type'}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm text-slate-600 mt-1">
|
|
||||||
{editingActivityType ? 'Update activity type information' : 'Add a new activity type for dealer claim management'}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-5 py-6 px-6 overflow-y-auto flex-1 min-h-0">
|
|
||||||
{/* Title Field */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
|
|
||||||
Title <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
placeholder="e.g., Riders Mania Claims, Legal Claims Reimbursement"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
||||||
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">Enter the activity type title</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Item Code Field */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="itemCode" className="text-sm font-semibold text-slate-900">
|
|
||||||
Item Code <span className="text-slate-400 font-normal text-xs">(Optional)</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="itemCode"
|
|
||||||
placeholder="e.g., 1, 2, 3"
|
|
||||||
value={formData.itemCode}
|
|
||||||
onChange={(e) => setFormData({ ...formData, itemCode: e.target.value })}
|
|
||||||
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">Optional item code for the activity type</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Taxation Type Field */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900">
|
|
||||||
Taxation Type <span className="text-slate-400 font-normal text-xs">(Optional)</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="taxationType"
|
|
||||||
placeholder="e.g., GST, VAT, Exempt"
|
|
||||||
value={formData.taxationType}
|
|
||||||
onChange={(e) => setFormData({ ...formData, taxationType: e.target.value })}
|
|
||||||
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">Optional taxation type for the activity</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SAP Reference Number Field */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900">
|
|
||||||
SAP Reference Number <span className="text-slate-400 font-normal text-xs">(Optional)</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="sapRefNo"
|
|
||||||
placeholder="e.g., SAP-12345"
|
|
||||||
value={formData.sapRefNo}
|
|
||||||
onChange={(e) => setFormData({ ...formData, sapRefNo: e.target.value })}
|
|
||||||
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">Optional SAP reference number</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowAddDialog(false)}
|
|
||||||
className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!formData.title.trim()}
|
|
||||||
className="h-11 bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
|
||||||
{editingActivityType ? 'Update Activity Type' : 'Add Activity Type'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
export { ConfigurationManager } from './ConfigurationManager';
|
export { ConfigurationManager } from './ConfigurationManager';
|
||||||
export { HolidayManager } from './HolidayManager';
|
export { HolidayManager } from './HolidayManager';
|
||||||
export { ActivityTypeManager } from './ActivityTypeManager';
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/components/ui/utils";
|
import { cn } from "@/components/ui/utils";
|
||||||
import { sanitizeHTML } from "@/utils/sanitizer";
|
|
||||||
|
|
||||||
interface FormattedDescriptionProps {
|
interface FormattedDescriptionProps {
|
||||||
content: string;
|
content: string;
|
||||||
@ -31,11 +30,10 @@ export function FormattedDescription({ content, className }: FormattedDescriptio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wrap the table in a scrollable container
|
// Wrap the table in a scrollable container
|
||||||
return `<div class="table-wrapper">${match}</div>`;
|
return `<div class="table-wrapper" style="overflow-x: auto; max-width: 100%; margin: 8px 0;">${match}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sanitize the content to prevent CSP violations (onclick, style tags, etc.)
|
return processed;
|
||||||
return sanitizeHTML(processed);
|
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import { ReLogo } from '@/assets';
|
|||||||
import notificationApi, { Notification } from '@/services/notificationApi';
|
import notificationApi, { Notification } from '@/services/notificationApi';
|
||||||
import { getSocket, joinUserRoom } from '@/utils/socket';
|
import { getSocket, joinUserRoom } from '@/utils/socket';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { TokenManager } from '@/utils/tokenManager';
|
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -37,17 +36,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Check if user is a Dealer
|
|
||||||
const isDealer = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const userData = TokenManager.getUserData();
|
|
||||||
return userData?.jobTitle === 'Dealer';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[PageLayout] Error checking dealer status:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get user initials for avatar
|
// Get user initials for avatar
|
||||||
const getUserInitials = () => {
|
const getUserInitials = () => {
|
||||||
try {
|
try {
|
||||||
@ -73,23 +61,19 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
||||||
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
|
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
|
||||||
{ id: 'requests', label: 'All Requests', icon: List },
|
{ id: 'requests', label: 'All Requests', icon: List },
|
||||||
{ id: 'my-requests', label: 'My Requests', icon: User, adminOnly: false },
|
{ id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
||||||
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add remaining menu items (exclude "My Requests" for dealers)
|
// Add remaining menu items
|
||||||
// if (!isDealer) {
|
|
||||||
// items.push({ id: 'my-requests', label: 'My Requests', icon: User });
|
|
||||||
// }
|
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
|
{ id: 'my-requests', label: 'My Requests', icon: User },
|
||||||
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
||||||
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
||||||
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
|
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
|
||||||
);
|
);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [isDealer]);
|
}, []);
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setSidebarOpen(!sidebarOpen);
|
setSidebarOpen(!sidebarOpen);
|
||||||
@ -276,7 +260,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Action in Sidebar - Right below menu items */}
|
{/* Quick Action in Sidebar - Right below menu items */}
|
||||||
{/* {!isDealer && ( */}
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={onNewRequest}
|
onClick={onNewRequest}
|
||||||
@ -287,7 +270,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
Raise New Request
|
Raise New Request
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* )} */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -316,16 +298,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
{!isDealer && (
|
<Button
|
||||||
<Button
|
onClick={onNewRequest}
|
||||||
onClick={onNewRequest}
|
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
|
||||||
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
|
size="sm"
|
||||||
size="sm"
|
>
|
||||||
>
|
<Plus className="w-4 h-4" />
|
||||||
<Plus className="w-4 h-4" />
|
New Request
|
||||||
New Request
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -183,7 +183,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="w-full justify-start text-left">
|
<Button variant="outline" className="w-full justify-start text-left">
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{formData.slaEndDate ? format(formData.slaEndDate, 'd MMM yyyy') : 'Pick a date'}
|
{formData.slaEndDate ? format(formData.slaEndDate, 'PPP') : 'Pick a date'}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0">
|
<PopoverContent className="w-auto p-0">
|
||||||
@ -378,7 +378,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
|
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
|
||||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
click to browse
|
Drag and drop files here, or click to browse
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
@ -8,16 +8,14 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
Package,
|
Package,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowLeft,
|
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Target,
|
Target,
|
||||||
|
X,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Check,
|
Check
|
||||||
AlertCircle
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { TokenManager } from '../../utils/tokenManager';
|
|
||||||
|
|
||||||
interface TemplateSelectionModalProps {
|
interface TemplateSelectionModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -41,8 +39,7 @@ const AVAILABLE_TEMPLATES = [
|
|||||||
'Document verification',
|
'Document verification',
|
||||||
'E-invoice generation',
|
'E-invoice generation',
|
||||||
'Credit note issuance'
|
'Credit note issuance'
|
||||||
],
|
]
|
||||||
disabled: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vendor-payment',
|
id: 'vendor-payment',
|
||||||
@ -58,32 +55,14 @@ const AVAILABLE_TEMPLATES = [
|
|||||||
'Invoice verification',
|
'Invoice verification',
|
||||||
'Multi-level approvals',
|
'Multi-level approvals',
|
||||||
'Payment scheduling'
|
'Payment scheduling'
|
||||||
],
|
]
|
||||||
disabled: true,
|
|
||||||
comingSoon: true
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
|
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||||
const [isDealer, setIsDealer] = useState(false);
|
|
||||||
|
|
||||||
// Check if user is a Dealer
|
|
||||||
useEffect(() => {
|
|
||||||
const userData = TokenManager.getUserData();
|
|
||||||
setIsDealer(userData?.jobTitle === 'Dealer');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelect = (templateId: string) => {
|
const handleSelect = (templateId: string) => {
|
||||||
// Don't allow selection if user is a dealer
|
|
||||||
if (isDealer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Don't allow selection if template is disabled
|
|
||||||
const template = AVAILABLE_TEMPLATES.find(t => t.id === templateId);
|
|
||||||
if (template?.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedTemplate(templateId);
|
setSelectedTemplate(templateId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -105,13 +84,12 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
|
Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
{/* Back arrow button - Top left */}
|
{/* Custom Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="!flex absolute top-6 left-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 items-center justify-center transition-all hover:scale-110"
|
className="absolute top-6 right-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 flex items-center justify-center transition-all hover:scale-110"
|
||||||
aria-label="Go back"
|
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
<X className="w-5 h-5 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Full Screen Content Container */}
|
{/* Full Screen Content Container */}
|
||||||
@ -139,7 +117,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
{AVAILABLE_TEMPLATES.map((template, index) => {
|
{AVAILABLE_TEMPLATES.map((template, index) => {
|
||||||
const Icon = template.icon;
|
const Icon = template.icon;
|
||||||
const isSelected = selectedTemplate === template.id;
|
const isSelected = selectedTemplate === template.id;
|
||||||
const isDisabled = isDealer || template.disabled;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -147,16 +124,14 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
whileHover={isDisabled ? {} : { scale: 1.03 }}
|
whileHover={{ scale: 1.03 }}
|
||||||
whileTap={isDisabled ? {} : { scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`h-full transition-all duration-300 border-2 ${
|
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
|
||||||
isDisabled
|
isSelected
|
||||||
? 'opacity-50 cursor-not-allowed border-gray-200'
|
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
||||||
: isSelected
|
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
||||||
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
|
||||||
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleSelect(template.id)}
|
onClick={() => handleSelect(template.id)}
|
||||||
>
|
>
|
||||||
@ -182,22 +157,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
<CardDescription className="text-sm leading-relaxed">
|
<CardDescription className="text-sm leading-relaxed">
|
||||||
{template.description}
|
{template.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
{isDealer && (
|
|
||||||
<div className="mt-3 flex items-start gap-2 p-2 bg-amber-50 border border-amber-200 rounded-lg">
|
|
||||||
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-xs text-amber-800">
|
|
||||||
Not accessible for Dealers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{template.comingSoon && !isDealer && (
|
|
||||||
<div className="mt-3 flex items-start gap-2 p-2 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-xs text-blue-800 font-semibold">
|
|
||||||
Coming Soon
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 space-y-4">
|
<CardContent className="pt-0 space-y-4">
|
||||||
@ -260,12 +219,12 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleContinue}
|
onClick={handleContinue}
|
||||||
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
|
disabled={!selectedTemplate}
|
||||||
size="lg"
|
size="lg"
|
||||||
className={`gap-2 px-8 ${
|
className={`gap-2 px-8 ${
|
||||||
selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
|
selectedTemplate
|
||||||
? 'bg-blue-600 hover:bg-blue-700'
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
: 'bg-gray-400 cursor-not-allowed'
|
: 'bg-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Continue with Template
|
Continue with Template
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
import { sanitizeHTML } from '../../utils/sanitizer';
|
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Avatar, AvatarFallback } from '../ui/avatar';
|
import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||||
@ -167,8 +166,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
// Simple mention highlighting
|
// Simple mention highlighting
|
||||||
const formatted = content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
|
return content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
|
||||||
return sanitizeHTML(formatted);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -197,10 +195,11 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
|
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
|
||||||
{!msg.isSystem && (
|
{!msg.isSystem && (
|
||||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||||
<AvatarFallback className={`text-white text-xs ${msg.user.role === 'Initiator' ? 'bg-re-green' :
|
<AvatarFallback className={`text-white text-xs ${
|
||||||
|
msg.user.role === 'Initiator' ? 'bg-re-green' :
|
||||||
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
||||||
'bg-re-light-green'
|
'bg-re-light-green'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.user.avatar}
|
{msg.user.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -307,8 +306,9 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
<div key={index} className="flex items-center gap-3">
|
<div key={index} className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className={`text-white text-xs ${participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
|
<AvatarFallback className={`text-white text-xs ${
|
||||||
}`}>
|
participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
|
||||||
|
}`}>
|
||||||
{participant.avatar}
|
{participant.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@ -24,8 +24,6 @@ interface AddApproverModalProps {
|
|||||||
requestTitle?: string;
|
requestTitle?: string;
|
||||||
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
|
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
|
||||||
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
|
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
|
||||||
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
|
|
||||||
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddApproverModal({
|
export function AddApproverModal({
|
||||||
@ -33,9 +31,7 @@ export function AddApproverModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
existingParticipants = [],
|
existingParticipants = [],
|
||||||
currentLevels = [],
|
currentLevels = []
|
||||||
maxApprovalLevels,
|
|
||||||
onPolicyViolation
|
|
||||||
}: AddApproverModalProps) {
|
}: AddApproverModalProps) {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [tatHours, setTatHours] = useState<number>(24);
|
const [tatHours, setTatHours] = useState<number>(24);
|
||||||
@ -144,36 +140,6 @@ export function AddApproverModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate against maxApprovalLevels policy
|
|
||||||
// Calculate the new total levels after adding this approver
|
|
||||||
// If inserting at a level that already exists, levels shift down, so total stays same
|
|
||||||
// If inserting at a new level (beyond current), total increases
|
|
||||||
const currentMaxLevel = currentLevels.length > 0
|
|
||||||
? Math.max(...currentLevels.map(l => l.levelNumber), 0)
|
|
||||||
: 0;
|
|
||||||
const newTotalLevels = selectedLevel > currentMaxLevel
|
|
||||||
? selectedLevel // New level beyond current max
|
|
||||||
: currentMaxLevel + 1; // Existing level, shifts everything down, adds one more
|
|
||||||
|
|
||||||
if (maxApprovalLevels && newTotalLevels > maxApprovalLevels) {
|
|
||||||
if (onPolicyViolation) {
|
|
||||||
onPolicyViolation([{
|
|
||||||
type: 'Maximum Approval Levels Exceeded',
|
|
||||||
message: `Adding an approver at level ${selectedLevel} would result in ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove an approver or contact your administrator.`,
|
|
||||||
currentValue: newTotalLevels,
|
|
||||||
maxValue: maxApprovalLevels
|
|
||||||
}]);
|
|
||||||
} else {
|
|
||||||
setValidationModal({
|
|
||||||
open: true,
|
|
||||||
type: 'error',
|
|
||||||
email: '',
|
|
||||||
message: `Cannot add approver. This would exceed the maximum allowed approval levels (${maxApprovalLevels}). Current request has ${currentMaxLevel} level(s).`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already a participant
|
// Check if user is already a participant
|
||||||
const existingParticipant = existingParticipants.find(
|
const existingParticipant = existingParticipants.find(
|
||||||
p => (p.email || '').toLowerCase() === emailToAdd
|
p => (p.email || '').toLowerCase() === emailToAdd
|
||||||
@ -428,20 +394,6 @@ export function AddApproverModal({
|
|||||||
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Max Approval Levels Note */}
|
|
||||||
{maxApprovalLevels && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2">
|
|
||||||
<p className="text-xs text-blue-800">
|
|
||||||
ℹ️ Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
|
||||||
{currentLevels.length > 0 && (
|
|
||||||
<span className="ml-2">
|
|
||||||
({Math.max(...currentLevels.map(l => l.levelNumber), 0)}/{maxApprovalLevels})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current Levels Display */}
|
{/* Current Levels Display */}
|
||||||
{currentLevels.length > 0 && (
|
{currentLevels.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -6,8 +6,7 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
|||||||
|
|
||||||
export interface SLAData {
|
export interface SLAData {
|
||||||
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
|
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
|
||||||
percentageUsed?: number;
|
percentageUsed: number;
|
||||||
percent?: number; // Simplified format (alternative to percentageUsed)
|
|
||||||
elapsedText: string;
|
elapsedText: string;
|
||||||
elapsedHours: number;
|
elapsedHours: number;
|
||||||
remainingText: string;
|
remainingText: string;
|
||||||
@ -28,12 +27,8 @@ export function SLAProgressBar({
|
|||||||
isPaused = false,
|
isPaused = false,
|
||||||
testId = 'sla-progress'
|
testId = 'sla-progress'
|
||||||
}: SLAProgressBarProps) {
|
}: SLAProgressBarProps) {
|
||||||
// Pure presentational component - no business logic
|
|
||||||
// If request is closed/approved/rejected or no SLA data, show status message
|
// If request is closed/approved/rejected or no SLA data, show status message
|
||||||
// Check if SLA has required fields (percentageUsed or at least some data)
|
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
||||||
const hasValidSLA = sla && (sla.percentageUsed !== undefined || sla.elapsedHours !== undefined);
|
|
||||||
|
|
||||||
if (!hasValidSLA || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
|
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
|
||||||
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
|
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
|
||||||
@ -52,7 +47,7 @@ export function SLAProgressBar({
|
|||||||
// Use percentage-based colors to match approver SLA tracker
|
// Use percentage-based colors to match approver SLA tracker
|
||||||
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
||||||
// Grey: When paused (frozen state)
|
// Grey: When paused (frozen state)
|
||||||
const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : 0;
|
const percentageUsed = sla.percentageUsed || 0;
|
||||||
const rawStatus = sla.status || 'on_track';
|
const rawStatus = sla.status || 'on_track';
|
||||||
|
|
||||||
// Determine colors based on percentage (matching ApprovalStepCard logic)
|
// Determine colors based on percentage (matching ApprovalStepCard logic)
|
||||||
@ -122,12 +117,12 @@ export function SLAProgressBar({
|
|||||||
className={`text-xs ${colors.badge}`}
|
className={`text-xs ${colors.badge}`}
|
||||||
data-testid={`${testId}-badge`}
|
data-testid={`${testId}-badge`}
|
||||||
>
|
>
|
||||||
{percentageUsed}% elapsed {isPaused && '(frozen)'}
|
{sla.percentageUsed || 0}% elapsed {isPaused && '(frozen)'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Progress
|
<Progress
|
||||||
value={percentageUsed}
|
value={sla.percentageUsed || 0}
|
||||||
className="h-3 mb-2"
|
className="h-3 mb-2"
|
||||||
indicatorClassName={colors.progress}
|
indicatorClassName={colors.progress}
|
||||||
data-testid={`${testId}-bar`}
|
data-testid={`${testId}-bar`}
|
||||||
@ -135,7 +130,7 @@ export function SLAProgressBar({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1">
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
<span className="text-gray-600" data-testid={`${testId}-elapsed`}>
|
<span className="text-gray-600" data-testid={`${testId}-elapsed`}>
|
||||||
{formatHoursMinutes(sla.elapsedHours || 0)} elapsed
|
{sla.elapsedText || formatHoursMinutes(sla.elapsedHours || 0)} elapsed
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${
|
className={`font-semibold ${
|
||||||
@ -151,7 +146,7 @@ export function SLAProgressBar({
|
|||||||
|
|
||||||
{sla.deadline && (
|
{sla.deadline && (
|
||||||
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
|
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
|
||||||
Due: {formatDateDDMMYYYY(sla.deadline, true)} • {percentageUsed}% elapsed
|
Due: {formatDateDDMMYYYY(sla.deadline, true)} • {sla.percentageUsed || 0}% elapsed
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -54,13 +54,13 @@ function ChartContainer({
|
|||||||
<div
|
<div
|
||||||
data-slot="chart"
|
data-slot="chart"
|
||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
style={getChartStyle(config)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
<RechartsPrimitive.ResponsiveContainer>
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
{children}
|
{children}
|
||||||
</RechartsPrimitive.ResponsiveContainer>
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
@ -69,39 +69,37 @@ function ChartContainer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChartStyle = (config: ChartConfig) => {
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
const colorConfig = Object.entries(config).filter(
|
const colorConfig = Object.entries(config).filter(
|
||||||
([, config]) => config.theme || config.color,
|
([, config]) => config.theme || config.color,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
return {};
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, string> = {};
|
return (
|
||||||
|
<style
|
||||||
colorConfig.forEach(([key, itemConfig]) => {
|
dangerouslySetInnerHTML={{
|
||||||
// For simplicity, we'll use the default color or the light theme color
|
__html: Object.entries(THEMES)
|
||||||
// If you need per-theme variables, they should be handled via CSS classes or media queries
|
.map(
|
||||||
// but applying them here as inline styles is CSP-safe.
|
([theme, prefix]) => `
|
||||||
const color = itemConfig.color || itemConfig.theme?.light;
|
${prefix} [data-chart=${id}] {
|
||||||
if (color) {
|
${colorConfig
|
||||||
styles[`--color-${key}`] = color;
|
.map(([key, itemConfig]) => {
|
||||||
}
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
// Handle dark theme if present
|
itemConfig.color;
|
||||||
const darkColor = itemConfig.theme?.dark;
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
if (darkColor) {
|
})
|
||||||
styles[`--color-${key}-dark`] = darkColor;
|
.join("\n")}
|
||||||
}
|
}
|
||||||
});
|
`,
|
||||||
|
)
|
||||||
return styles as React.CSSProperties;
|
.join("\n"),
|
||||||
};
|
}}
|
||||||
|
/>
|
||||||
// Deprecated: Kept for backward compatibility if needed in other files.
|
);
|
||||||
const ChartStyle = () => {
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
@ -318,8 +316,8 @@ function getPayloadConfigFromPayload(
|
|||||||
|
|
||||||
const payloadPayload =
|
const payloadPayload =
|
||||||
"payload" in payload &&
|
"payload" in payload &&
|
||||||
typeof payload.payload === "object" &&
|
typeof payload.payload === "object" &&
|
||||||
payload.payload !== null
|
payload.payload !== null
|
||||||
? payload.payload
|
? payload.payload
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@ -1,186 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { format, parse, isValid } from "date-fns";
|
|
||||||
import { Calendar as CalendarIcon } from "lucide-react";
|
|
||||||
import { cn } from "./utils";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { Calendar } from "./calendar";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "./popover";
|
|
||||||
|
|
||||||
export interface CustomDatePickerProps {
|
|
||||||
/**
|
|
||||||
* Selected date value as string in YYYY-MM-DD format (for form compatibility)
|
|
||||||
* or Date object
|
|
||||||
*/
|
|
||||||
value?: string | Date | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback when date changes. Returns date string in YYYY-MM-DD format
|
|
||||||
*/
|
|
||||||
onChange?: (date: string | null) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum selectable date as string (YYYY-MM-DD) or Date object
|
|
||||||
*/
|
|
||||||
minDate?: string | Date | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum selectable date as string (YYYY-MM-DD) or Date object
|
|
||||||
*/
|
|
||||||
maxDate?: string | Date | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder text
|
|
||||||
*/
|
|
||||||
placeholderText?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the date picker is disabled
|
|
||||||
*/
|
|
||||||
disabled?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional CSS classes
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSS classes for the wrapper div
|
|
||||||
*/
|
|
||||||
wrapperClassName?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error state - shows red border
|
|
||||||
*/
|
|
||||||
error?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display format (default: "dd/MM/yyyy")
|
|
||||||
*/
|
|
||||||
displayFormat?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ID for accessibility
|
|
||||||
*/
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reusable DatePicker component with consistent dd/MM/yyyy format and button trigger.
|
|
||||||
* Uses native Calendar component wrapped in a Popover.
|
|
||||||
*/
|
|
||||||
export function CustomDatePicker({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
minDate,
|
|
||||||
maxDate,
|
|
||||||
placeholderText = "dd/mm/yyyy",
|
|
||||||
disabled = false,
|
|
||||||
className,
|
|
||||||
wrapperClassName,
|
|
||||||
error = false,
|
|
||||||
displayFormat = "dd/MM/yyyy",
|
|
||||||
id,
|
|
||||||
}: CustomDatePickerProps) {
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
|
||||||
|
|
||||||
// Convert input value to Date object for Calendar
|
|
||||||
const selectedDate = React.useMemo(() => {
|
|
||||||
if (!value) return undefined;
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return isValid(value) ? value : undefined;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = parse(value, "yyyy-MM-dd", new Date());
|
|
||||||
return isValid(parsed) ? parsed : undefined;
|
|
||||||
} catch (e) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// Convert minDate
|
|
||||||
const minDateObj = React.useMemo(() => {
|
|
||||||
if (!minDate) return undefined;
|
|
||||||
if (minDate instanceof Date) return isValid(minDate) ? minDate : undefined;
|
|
||||||
if (typeof minDate === "string") {
|
|
||||||
const parsed = parse(minDate, "yyyy-MM-dd", new Date());
|
|
||||||
return isValid(parsed) ? parsed : undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [minDate]);
|
|
||||||
|
|
||||||
// Convert maxDate
|
|
||||||
const maxDateObj = React.useMemo(() => {
|
|
||||||
if (!maxDate) return undefined;
|
|
||||||
if (maxDate instanceof Date) return isValid(maxDate) ? maxDate : undefined;
|
|
||||||
if (typeof maxDate === "string") {
|
|
||||||
const parsed = parse(maxDate, "yyyy-MM-dd", new Date());
|
|
||||||
return isValid(parsed) ? parsed : undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [maxDate]);
|
|
||||||
|
|
||||||
const handleSelect = (date: Date | undefined) => {
|
|
||||||
setIsPopoverOpen(false);
|
|
||||||
if (!onChange) return;
|
|
||||||
|
|
||||||
if (!date) {
|
|
||||||
onChange(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return YYYY-MM-DD string
|
|
||||||
onChange(format(date, "yyyy-MM-dd"));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("relative", wrapperClassName)}>
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
id={id}
|
|
||||||
disabled={disabled}
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start text-left font-normal",
|
|
||||||
!selectedDate && "text-muted-foreground",
|
|
||||||
error && "border-destructive ring-destructive/20",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
||||||
{selectedDate ? (
|
|
||||||
format(selectedDate, displayFormat)
|
|
||||||
) : (
|
|
||||||
<span>{placeholderText}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={selectedDate}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
disabled={(date) => {
|
|
||||||
if (minDateObj && date < minDateObj) return true;
|
|
||||||
if (maxDateObj && date > maxDateObj) return true;
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
initialFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomDatePicker;
|
|
||||||
|
|
||||||
@ -3,7 +3,6 @@ import { cn } from "./utils";
|
|||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
|
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
||||||
import { sanitizeHTML } from "@/utils/sanitizer";
|
|
||||||
|
|
||||||
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
value: string;
|
value: string;
|
||||||
@ -60,8 +59,7 @@ export function RichTextEditor({
|
|||||||
// Only update if the value actually changed externally
|
// Only update if the value actually changed externally
|
||||||
const currentValue = editorRef.current.innerHTML;
|
const currentValue = editorRef.current.innerHTML;
|
||||||
if (currentValue !== value) {
|
if (currentValue !== value) {
|
||||||
// Sanitize incoming content
|
editorRef.current.innerHTML = value || '';
|
||||||
editorRef.current.innerHTML = sanitizeHTML(value || '');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
@ -171,6 +169,9 @@ export function RichTextEditor({
|
|||||||
// Wrap table in scrollable container for mobile
|
// Wrap table in scrollable container for mobile
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'table-wrapper';
|
wrapper.className = 'table-wrapper';
|
||||||
|
wrapper.style.overflowX = 'auto';
|
||||||
|
wrapper.style.maxWidth = '100%';
|
||||||
|
wrapper.style.margin = '8px 0';
|
||||||
wrapper.appendChild(table);
|
wrapper.appendChild(table);
|
||||||
fragment.appendChild(wrapper);
|
fragment.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
@ -181,7 +182,7 @@ export function RichTextEditor({
|
|||||||
const innerHTML = element.innerHTML;
|
const innerHTML = element.innerHTML;
|
||||||
// Remove style tags and comments from inner HTML
|
// Remove style tags and comments from inner HTML
|
||||||
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
.replace(/<!--[\s\S]*?-->/g, '');
|
.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
p.innerHTML = cleaned;
|
p.innerHTML = cleaned;
|
||||||
p.removeAttribute('style');
|
p.removeAttribute('style');
|
||||||
p.removeAttribute('class');
|
p.removeAttribute('class');
|
||||||
@ -232,9 +233,9 @@ export function RichTextEditor({
|
|||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
|
|
||||||
// Trigger onChange with sanitized content
|
// Trigger onChange
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
}, [onChange, cleanWordHTML]);
|
}, [onChange, cleanWordHTML]);
|
||||||
|
|
||||||
@ -272,34 +273,34 @@ export function RichTextEditor({
|
|||||||
if (style.textAlign === 'right') formats.add('right');
|
if (style.textAlign === 'right') formats.add('right');
|
||||||
if (style.textAlign === 'left') formats.add('left');
|
if (style.textAlign === 'left') formats.add('left');
|
||||||
|
|
||||||
// Convert RGB/RGBA to hex for comparison
|
// Convert RGB/RGBA to hex for comparison
|
||||||
const colorToHex = (color: string): string | null => {
|
const colorToHex = (color: string): string | null => {
|
||||||
// If already hex format
|
// If already hex format
|
||||||
if (color.startsWith('#')) {
|
if (color.startsWith('#')) {
|
||||||
return color.toUpperCase();
|
return color.toUpperCase();
|
||||||
}
|
}
|
||||||
// If RGB/RGBA format
|
// If RGB/RGBA format
|
||||||
const result = color.match(/\d+/g);
|
const result = color.match(/\d+/g);
|
||||||
if (!result || result.length < 3) return null;
|
if (!result || result.length < 3) return null;
|
||||||
const r = result[0];
|
const r = result[0];
|
||||||
const g = result[1];
|
const g = result[1];
|
||||||
const b = result[2];
|
const b = result[2];
|
||||||
if (!r || !g || !b) return null;
|
if (!r || !g || !b) return null;
|
||||||
const rHex = parseInt(r).toString(16).padStart(2, '0');
|
const rHex = parseInt(r).toString(16).padStart(2, '0');
|
||||||
const gHex = parseInt(g).toString(16).padStart(2, '0');
|
const gHex = parseInt(g).toString(16).padStart(2, '0');
|
||||||
const bHex = parseInt(b).toString(16).padStart(2, '0');
|
const bHex = parseInt(b).toString(16).padStart(2, '0');
|
||||||
return `#${rHex}${gHex}${bHex}`.toUpperCase();
|
return `#${rHex}${gHex}${bHex}`.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for background color (highlight)
|
// Check for background color (highlight)
|
||||||
const bgColor = style.backgroundColor;
|
const bgColor = style.backgroundColor;
|
||||||
// Check if background color is set and not transparent/default
|
// Check if background color is set and not transparent/default
|
||||||
if (bgColor &&
|
if (bgColor &&
|
||||||
bgColor !== 'rgba(0, 0, 0, 0)' &&
|
bgColor !== 'rgba(0, 0, 0, 0)' &&
|
||||||
bgColor !== 'transparent' &&
|
bgColor !== 'transparent' &&
|
||||||
bgColor !== 'rgb(255, 255, 255)' &&
|
bgColor !== 'rgb(255, 255, 255)' &&
|
||||||
bgColor !== '#ffffff' &&
|
bgColor !== '#ffffff' &&
|
||||||
bgColor !== '#FFFFFF') {
|
bgColor !== '#FFFFFF') {
|
||||||
formats.add('highlight');
|
formats.add('highlight');
|
||||||
const hexColor = colorToHex(bgColor);
|
const hexColor = colorToHex(bgColor);
|
||||||
if (hexColor) {
|
if (hexColor) {
|
||||||
@ -327,8 +328,8 @@ export function RichTextEditor({
|
|||||||
const hexTextColor = colorToHex(textColor);
|
const hexTextColor = colorToHex(textColor);
|
||||||
// Check if text color is set and not default black
|
// Check if text color is set and not default black
|
||||||
if (textColor && hexTextColor &&
|
if (textColor && hexTextColor &&
|
||||||
textColor !== 'rgba(0, 0, 0, 0)' &&
|
textColor !== 'rgba(0, 0, 0, 0)' &&
|
||||||
hexTextColor !== '#000000') {
|
hexTextColor !== '#000000') {
|
||||||
formats.add('textColor');
|
formats.add('textColor');
|
||||||
// Find matching color from our palette
|
// Find matching color from our palette
|
||||||
const matchedColor = HIGHLIGHT_COLORS.find(c => {
|
const matchedColor = HIGHLIGHT_COLORS.find(c => {
|
||||||
@ -379,7 +380,7 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check active formats after a short delay
|
// Check active formats after a short delay
|
||||||
@ -421,7 +422,7 @@ export function RichTextEditor({
|
|||||||
const style = window.getComputedStyle(element);
|
const style = window.getComputedStyle(element);
|
||||||
const bgColor = style.backgroundColor;
|
const bgColor = style.backgroundColor;
|
||||||
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
|
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
|
||||||
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
|
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
|
||||||
// Convert to hex and compare
|
// Convert to hex and compare
|
||||||
const colorToHex = (c: string): string | null => {
|
const colorToHex = (c: string): string | null => {
|
||||||
if (c.startsWith('#')) return c.toUpperCase();
|
if (c.startsWith('#')) return c.toUpperCase();
|
||||||
@ -531,7 +532,7 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popover
|
// Close popover
|
||||||
@ -635,7 +636,7 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popover
|
// Close popover
|
||||||
@ -648,7 +649,7 @@ export function RichTextEditor({
|
|||||||
// Handle input changes
|
// Handle input changes
|
||||||
const handleInput = React.useCallback(() => {
|
const handleInput = React.useCallback(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
checkActiveFormats();
|
checkActiveFormats();
|
||||||
}, [onChange, checkActiveFormats]);
|
}, [onChange, checkActiveFormats]);
|
||||||
@ -684,7 +685,7 @@ export function RichTextEditor({
|
|||||||
const handleBlur = React.useCallback(() => {
|
const handleBlur = React.useCallback(() => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
||||||
import { sanitizeHTML } from '@/utils/sanitizer';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
@ -59,11 +58,9 @@ interface WorkNoteChatSimpleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
const formattedContent = content
|
return content
|
||||||
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
|
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
|
||||||
.replace(/\n/g, '<br />');
|
.replace(/\n/g, '<br />');
|
||||||
|
|
||||||
return sanitizeHTML(formattedContent);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileIcon = ({ type }: { type: string }) => {
|
const FileIcon = ({ type }: { type: string }) => {
|
||||||
@ -143,7 +140,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
if (details?.workflow?.requestId) {
|
if (details?.workflow?.requestId) {
|
||||||
joinedId = details.workflow.requestId;
|
joinedId = details.workflow.requestId;
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
try {
|
try {
|
||||||
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
||||||
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
||||||
@ -192,7 +189,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
try { (window as any).__wn_cleanup?.(); } catch { }
|
try { (window as any).__wn_cleanup?.(); } catch {}
|
||||||
};
|
};
|
||||||
}, [requestId, currentUserId]);
|
}, [requestId, currentUserId]);
|
||||||
|
|
||||||
@ -221,7 +218,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
};
|
};
|
||||||
}) : [];
|
}) : [];
|
||||||
setMessages(mapped as any);
|
setMessages(mapped as any);
|
||||||
} catch { }
|
} catch {}
|
||||||
}
|
}
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
@ -260,7 +257,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
setMessages(mapped);
|
setMessages(mapped);
|
||||||
} catch { }
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@ -397,10 +394,11 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
|
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
|
||||||
{!msg.isSystem && !isCurrentUser && (
|
{!msg.isSystem && !isCurrentUser && (
|
||||||
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
|
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
|
||||||
<AvatarFallback className={`text-white font-semibold text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
|
<AvatarFallback className={`text-white font-semibold text-sm ${
|
||||||
msg.user.role === 'Approver' ? 'bg-blue-600' :
|
msg.user.role === 'Initiator' ? 'bg-green-600' :
|
||||||
'bg-slate-600'
|
msg.user.role === 'Approver' ? 'bg-blue-600' :
|
||||||
}`}>
|
'bg-slate-600'
|
||||||
|
}`}>
|
||||||
{msg.user.avatar}
|
{msg.user.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -453,70 +451,70 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
const attachmentId = attachment.attachmentId || attachment.attachment_id;
|
const attachmentId = attachment.attachmentId || attachment.attachment_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
|
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<FileIcon type={fileType} />
|
<FileIcon type={fileType} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-700 truncate">
|
<p className="text-sm font-medium text-gray-700 truncate">
|
||||||
{fileName}
|
{fileName}
|
||||||
|
</p>
|
||||||
|
{fileSize && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formatFileSize(fileSize)}
|
||||||
</p>
|
</p>
|
||||||
{fileSize && (
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{formatFileSize(fileSize)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview button for images and PDFs */}
|
|
||||||
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
|
|
||||||
setPreviewFile({
|
|
||||||
fileName,
|
|
||||||
fileType,
|
|
||||||
fileUrl: previewUrl,
|
|
||||||
fileSize,
|
|
||||||
attachmentId
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
title="Preview file"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Download button */}
|
{/* Preview button for images and PDFs */}
|
||||||
|
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
|
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
|
||||||
onClick={async (e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
|
||||||
if (!attachmentId) {
|
setPreviewFile({
|
||||||
toast.error('Cannot download: Attachment ID missing');
|
fileName,
|
||||||
return;
|
fileType,
|
||||||
}
|
fileUrl: previewUrl,
|
||||||
|
fileSize,
|
||||||
try {
|
attachmentId
|
||||||
await downloadWorkNoteAttachment(attachmentId);
|
});
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to download file');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
title="Download file"
|
title="Preview file"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!attachmentId) {
|
||||||
|
toast.error('Cannot download: Attachment ID missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadWorkNoteAttachment(attachmentId);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to download file');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Download file"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -530,10 +528,11 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => addReaction(msg.id, reaction.emoji)}
|
onClick={() => addReaction(msg.id, reaction.emoji)}
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${
|
||||||
|
reaction.users.includes('You')
|
||||||
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{reaction.emoji}</span>
|
<span>{reaction.emoji}</span>
|
||||||
<span className="text-xs font-medium">{reaction.users.length}</span>
|
<span className="text-xs font-medium">{reaction.users.length}</span>
|
||||||
|
|||||||
@ -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';
|
||||||
@ -0,0 +1,650 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
Check,
|
||||||
|
Receipt,
|
||||||
|
Building,
|
||||||
|
MapPin,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
Info,
|
||||||
|
FileText,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getAllDealers, getDealerInfo, formatDealerAddress } from '@/utils/dealerDatabase';
|
||||||
|
|
||||||
|
interface ClaimManagementWizardProps {
|
||||||
|
onBack?: () => void;
|
||||||
|
onSubmit?: (claimData: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAIM_TYPES = [
|
||||||
|
'Marketing Activity',
|
||||||
|
'Promotional Event',
|
||||||
|
'Dealer Training',
|
||||||
|
'Infrastructure Development',
|
||||||
|
'Customer Experience Initiative',
|
||||||
|
'Service Campaign'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch dealers from database
|
||||||
|
const DEALERS = getAllDealers();
|
||||||
|
|
||||||
|
const STEP_NAMES = [
|
||||||
|
'Claim Details',
|
||||||
|
'Review & Submit'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
activityName: '',
|
||||||
|
activityType: '',
|
||||||
|
dealerCode: '',
|
||||||
|
dealerName: '',
|
||||||
|
dealerEmail: '',
|
||||||
|
dealerPhone: '',
|
||||||
|
dealerAddress: '',
|
||||||
|
activityDate: undefined as Date | undefined,
|
||||||
|
location: '',
|
||||||
|
requestDescription: '',
|
||||||
|
periodStartDate: undefined as Date | undefined,
|
||||||
|
periodEndDate: undefined as Date | undefined,
|
||||||
|
estimatedBudget: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSteps = STEP_NAMES.length;
|
||||||
|
|
||||||
|
const updateFormData = (field: string, value: any) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStepValid = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return formData.activityName &&
|
||||||
|
formData.activityType &&
|
||||||
|
formData.dealerCode &&
|
||||||
|
formData.dealerName &&
|
||||||
|
formData.activityDate &&
|
||||||
|
formData.location &&
|
||||||
|
formData.requestDescription;
|
||||||
|
case 2:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (currentStep < totalSteps && isStepValid()) {
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevStep = () => {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDealerChange = (dealerCode: string) => {
|
||||||
|
const dealer = getDealerInfo(dealerCode);
|
||||||
|
if (dealer) {
|
||||||
|
updateFormData('dealerCode', dealer.code);
|
||||||
|
updateFormData('dealerName', dealer.name);
|
||||||
|
updateFormData('dealerEmail', dealer.email);
|
||||||
|
updateFormData('dealerPhone', dealer.phone);
|
||||||
|
updateFormData('dealerAddress', formatDealerAddress(dealer));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const claimData = {
|
||||||
|
...formData,
|
||||||
|
templateType: 'claim-management',
|
||||||
|
submittedAt: new Date().toISOString(),
|
||||||
|
status: 'pending',
|
||||||
|
currentStep: 'initiator-review',
|
||||||
|
workflowSteps: [
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
name: 'Initiator Evaluation',
|
||||||
|
status: 'pending',
|
||||||
|
approver: 'Current User (Initiator)',
|
||||||
|
description: 'Review and confirm all claim details and documents'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
name: 'IO Confirmation',
|
||||||
|
status: 'waiting',
|
||||||
|
approver: 'System',
|
||||||
|
description: 'Automatic IO generation upon initiator approval'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
name: 'Department Lead Approval',
|
||||||
|
status: 'waiting',
|
||||||
|
approver: 'Department Lead',
|
||||||
|
description: 'Budget blocking and final approval'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
name: 'Document Submission',
|
||||||
|
status: 'waiting',
|
||||||
|
approver: 'Dealer',
|
||||||
|
description: 'Dealer submits completion documents'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 5,
|
||||||
|
name: 'Document Verification',
|
||||||
|
status: 'waiting',
|
||||||
|
approver: 'Initiator',
|
||||||
|
description: 'Verify completion documents'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 6,
|
||||||
|
name: 'E-Invoice Generation',
|
||||||
|
status: 'waiting',
|
||||||
|
approver: 'System',
|
||||||
|
description: 'Auto-generate e-invoice based on approved amount'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 7,
|
||||||
|
name: 'Credit Note Issuance',
|
||||||
|
status: 'waiting',
|
||||||
|
approver: 'Finance',
|
||||||
|
description: 'Issue credit note to dealer'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.success('Claim Request Created', {
|
||||||
|
description: 'Your claim management request has been submitted successfully.'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit(claimData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Receipt className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Claim Details</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Provide comprehensive information about your claim request
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
{/* Activity Name and Type */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="activityName" className="text-base font-semibold">Activity Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="activityName"
|
||||||
|
placeholder="e.g., Himalayan Adventure Fest 2024"
|
||||||
|
value={formData.activityName}
|
||||||
|
onChange={(e) => updateFormData('activityName', e.target.value)}
|
||||||
|
className="mt-2 h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
|
||||||
|
<Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}>
|
||||||
|
<SelectTrigger className="mt-2 h-12">
|
||||||
|
<SelectValue placeholder="Select activity type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CLAIM_TYPES.map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dealer Selection */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dealer" className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
|
||||||
|
<Select value={formData.dealerCode} onValueChange={handleDealerChange}>
|
||||||
|
<SelectTrigger className="mt-2 h-12">
|
||||||
|
<SelectValue placeholder="Select dealer">
|
||||||
|
{formData.dealerCode && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm">{formData.dealerCode}</span>
|
||||||
|
<span className="text-gray-400">•</span>
|
||||||
|
<span>{formData.dealerName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DEALERS.map((dealer) => (
|
||||||
|
<SelectItem key={dealer.code} value={dealer.code}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm font-semibold">{dealer.code}</span>
|
||||||
|
<span className="text-gray-400">•</span>
|
||||||
|
<span>{dealer.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formData.dealerCode && (
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
Selected: <span className="font-semibold">{formData.dealerName}</span> ({formData.dealerCode})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date and Location */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-base font-semibold">Date *</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left mt-2 h-12"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{formData.activityDate ? format(formData.activityDate, 'PPP') : 'Select date'}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={formData.activityDate}
|
||||||
|
onSelect={(date) => updateFormData('activityDate', date)}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="location" className="text-base font-semibold">Location *</Label>
|
||||||
|
<Input
|
||||||
|
id="location"
|
||||||
|
placeholder="e.g., Mumbai, Maharashtra"
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) => updateFormData('location', e.target.value)}
|
||||||
|
className="mt-2 h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request Detail */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="requestDescription" className="text-base font-semibold">Request in Detail - Brief Requirement *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="requestDescription"
|
||||||
|
placeholder="Provide a detailed description of your claim requirement..."
|
||||||
|
value={formData.requestDescription}
|
||||||
|
onChange={(e) => updateFormData('requestDescription', e.target.value)}
|
||||||
|
className="mt-2 min-h-[120px]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Include key details about the claim, objectives, and expected outcomes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period (Optional) */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Label className="text-base font-semibold">Period (If Any)</Label>
|
||||||
|
<Badge variant="secondary" className="text-xs">Optional</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-gray-600">Start Date</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left mt-2 h-12"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Start date'}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={formData.periodStartDate}
|
||||||
|
onSelect={(date) => updateFormData('periodStartDate', date)}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-gray-600">End Date</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left mt-2 h-12"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={formData.periodEndDate}
|
||||||
|
onSelect={(date) => updateFormData('periodEndDate', date)}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(formData.periodStartDate || formData.periodEndDate) && (
|
||||||
|
<p className="text-xs text-gray-600 mt-2">
|
||||||
|
{formData.periodStartDate && formData.periodEndDate
|
||||||
|
? `Period: ${format(formData.periodStartDate, 'MMM dd, yyyy')} - ${format(formData.periodEndDate, 'MMM dd, yyyy')}`
|
||||||
|
: 'Please select both start and end dates for the period'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircle className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Submit</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Review your claim details before submission
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
{/* Activity Information */}
|
||||||
|
<Card className="border-2">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-blue-50 to-indigo-50">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Receipt className="w-5 h-5 text-blue-600" />
|
||||||
|
Activity Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Activity Name</Label>
|
||||||
|
<p className="font-semibold text-gray-900 mt-1">{formData.activityName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Activity Type</Label>
|
||||||
|
<Badge variant="secondary" className="mt-1">{formData.activityType}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dealer Information */}
|
||||||
|
<Card className="border-2">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-green-50 to-emerald-50">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building className="w-5 h-5 text-green-600" />
|
||||||
|
Dealer Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Dealer Code</Label>
|
||||||
|
<p className="font-semibold text-gray-900 mt-1 font-mono">{formData.dealerCode}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Dealer Name</Label>
|
||||||
|
<p className="font-semibold text-gray-900 mt-1">{formData.dealerName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Email</Label>
|
||||||
|
<p className="text-gray-900 mt-1">{formData.dealerEmail}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Phone</Label>
|
||||||
|
<p className="text-gray-900 mt-1">{formData.dealerPhone}</p>
|
||||||
|
</div>
|
||||||
|
{formData.dealerAddress && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Address</Label>
|
||||||
|
<p className="text-gray-900 mt-1">{formData.dealerAddress}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Date & Location */}
|
||||||
|
<Card className="border-2">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-purple-50 to-pink-50">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-5 h-5 text-purple-600" />
|
||||||
|
Date & Location
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Date</Label>
|
||||||
|
<p className="font-semibold text-gray-900 mt-1">
|
||||||
|
{formData.activityDate ? format(formData.activityDate, 'PPP') : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Location</Label>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<MapPin className="w-4 h-4 text-gray-500" />
|
||||||
|
<p className="font-semibold text-gray-900">{formData.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{formData.estimatedBudget && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Estimated Budget</Label>
|
||||||
|
<p className="text-xl font-bold text-blue-900 mt-1">{formData.estimatedBudget}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Request Details */}
|
||||||
|
<Card className="border-2">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-orange-50 to-amber-50">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-orange-600" />
|
||||||
|
Request Details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
|
||||||
|
<div className="mt-2 p-4 bg-gray-50 rounded-lg border">
|
||||||
|
<p className="text-gray-900 whitespace-pre-wrap">{formData.requestDescription}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Period (if provided) */}
|
||||||
|
{(formData.periodStartDate || formData.periodEndDate) && (
|
||||||
|
<Card className="border-2">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-cyan-50 to-blue-50">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5 text-cyan-600" />
|
||||||
|
Period
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Start Date</Label>
|
||||||
|
<p className="font-semibold text-gray-900 mt-1">
|
||||||
|
{formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Not specified'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">End Date</Label>
|
||||||
|
<p className="font-semibold text-gray-900 mt-1">
|
||||||
|
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'Not specified'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Message */}
|
||||||
|
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Info className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-blue-900 mb-1">Ready to Submit</p>
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
Please review all the information above. Once submitted, your claim request will enter the approval workflow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-gradient-to-br from-gray-50 to-gray-100 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-6 overflow-y-auto">
|
||||||
|
<div className="max-w-6xl mx-auto pb-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 sm:mb-8">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="mb-3 sm:mb-4 gap-2 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
<span className="hidden sm:inline">Back to Templates</span>
|
||||||
|
<span className="sm:hidden">Back</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
|
||||||
|
<div>
|
||||||
|
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
|
||||||
|
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900">New Claim Request</h1>
|
||||||
|
<p className="text-sm sm:text-base text-gray-600 mt-1">
|
||||||
|
Step {currentStep} of {totalSteps}: <span className="hidden sm:inline">{STEP_NAMES[currentStep - 1]}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mt-4 sm:mt-6">
|
||||||
|
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
|
||||||
|
<div className="flex justify-between mt-2 px-1">
|
||||||
|
{STEP_NAMES.map((_name, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={`text-xs sm:text-sm ${
|
||||||
|
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<Card className="mb-6 sm:mb-8">
|
||||||
|
<CardContent className="p-4 sm:p-6 lg:p-8">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{renderStepContent()}
|
||||||
|
</AnimatePresence>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between gap-3 sm:gap-0 pb-4 sm:pb-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={prevStep}
|
||||||
|
disabled={currentStep === 1}
|
||||||
|
className="gap-2 w-full sm:w-auto order-2 sm:order-1"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{currentStep < totalSteps ? (
|
||||||
|
<Button
|
||||||
|
onClick={nextStep}
|
||||||
|
disabled={!isStepValid()}
|
||||||
|
className="gap-2 w-full sm:w-auto order-1 sm:order-2"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isStepValid()}
|
||||||
|
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto order-1 sm:order-2"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
Submit Claim Request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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';
|
||||||
@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
|
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
|
||||||
import { FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
|
import { FormData } from '@/hooks/useCreateRequestForm';
|
||||||
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
||||||
import { ensureUserExists } from '@/services/userApi';
|
import { ensureUserExists } from '@/services/userApi';
|
||||||
|
|
||||||
@ -15,8 +15,6 @@ interface ApprovalWorkflowStepProps {
|
|||||||
formData: FormData;
|
formData: FormData;
|
||||||
updateFormData: (field: keyof FormData, value: any) => void;
|
updateFormData: (field: keyof FormData, value: any) => void;
|
||||||
onValidationError: (error: { type: string; email: string; message: string }) => void;
|
onValidationError: (error: { type: string; email: string; message: string }) => void;
|
||||||
systemPolicy: SystemPolicy;
|
|
||||||
onPolicyViolation: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,9 +33,7 @@ interface ApprovalWorkflowStepProps {
|
|||||||
export function ApprovalWorkflowStep({
|
export function ApprovalWorkflowStep({
|
||||||
formData,
|
formData,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
onValidationError,
|
onValidationError
|
||||||
systemPolicy,
|
|
||||||
onPolicyViolation
|
|
||||||
}: ApprovalWorkflowStepProps) {
|
}: ApprovalWorkflowStepProps) {
|
||||||
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
||||||
|
|
||||||
@ -222,29 +218,17 @@ export function ApprovalWorkflowStep({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentCount = formData.approverCount || 1;
|
const currentCount = formData.approverCount || 1;
|
||||||
const newCount = currentCount + 1;
|
const newCount = Math.min(10, currentCount + 1);
|
||||||
|
|
||||||
// Validate against system policy
|
|
||||||
if (newCount > systemPolicy.maxApprovalLevels) {
|
|
||||||
onPolicyViolation([{
|
|
||||||
type: 'Maximum Approval Levels Exceeded',
|
|
||||||
message: `Cannot add more than ${systemPolicy.maxApprovalLevels} approval levels. Please remove an approver level or contact your administrator.`,
|
|
||||||
currentValue: newCount,
|
|
||||||
maxValue: systemPolicy.maxApprovalLevels
|
|
||||||
}]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFormData('approverCount', newCount);
|
updateFormData('approverCount', newCount);
|
||||||
}}
|
}}
|
||||||
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
|
disabled={(formData.approverCount || 1) >= 10}
|
||||||
data-testid="approval-workflow-increase-count"
|
data-testid="approval-workflow-increase-count"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
Maximum {systemPolicy.maxApprovalLevels} approver{systemPolicy.maxApprovalLevels !== 1 ? 's' : ''} allowed. Each approver will review sequentially.
|
Maximum 10 approvers allowed. Each approver will review sequentially.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -111,16 +111,16 @@ export function DocumentsStep({
|
|||||||
const type = (doc.fileType || doc.file_type || '').toLowerCase();
|
const type = (doc.fileType || doc.file_type || '').toLowerCase();
|
||||||
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
|
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
|
||||||
return type.includes('image') || type.includes('pdf') ||
|
return type.includes('image') || type.includes('pdf') ||
|
||||||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
||||||
name.endsWith('.png') || name.endsWith('.gif') ||
|
name.endsWith('.png') || name.endsWith('.gif') ||
|
||||||
name.endsWith('.pdf');
|
name.endsWith('.pdf');
|
||||||
} else {
|
} else {
|
||||||
const type = (doc.type || '').toLowerCase();
|
const type = (doc.type || '').toLowerCase();
|
||||||
const name = (doc.name || '').toLowerCase();
|
const name = (doc.name || '').toLowerCase();
|
||||||
return type.includes('image') || type.includes('pdf') ||
|
return type.includes('image') || type.includes('pdf') ||
|
||||||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
||||||
name.endsWith('.png') || name.endsWith('.gif') ||
|
name.endsWith('.png') || name.endsWith('.gif') ||
|
||||||
name.endsWith('.pdf');
|
name.endsWith('.pdf');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ export function DocumentsStep({
|
|||||||
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
click to browse
|
Drag and drop files here, or click to browse
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@ -3,11 +3,11 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Check, Clock, Users, Flame, Target, TrendingUp, FolderOpen, ArrowLeft, Info } from 'lucide-react';
|
import { Check, Clock, Users, Info, Flame, Target, TrendingUp, FolderOpen, ArrowLeft } from 'lucide-react';
|
||||||
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
|
|
||||||
interface TemplateSelectionStepProps {
|
interface TemplateSelectionStepProps {
|
||||||
templates: RequestTemplate[];
|
templates: RequestTemplate[];
|
||||||
@ -52,18 +52,18 @@ export function TemplateSelectionStep({
|
|||||||
const displayTemplates = viewMode === 'main'
|
const displayTemplates = viewMode === 'main'
|
||||||
? [
|
? [
|
||||||
...templates,
|
...templates,
|
||||||
// {
|
{
|
||||||
// id: 'admin-templates-category',
|
id: 'admin-templates-category',
|
||||||
// name: 'Admin Templates',
|
name: 'Admin Templates',
|
||||||
// description: 'Browse standardized request workflows created by your organization administrators',
|
description: 'Browse standardized request workflows created by your organization administrators',
|
||||||
// category: 'Organization',
|
category: 'Organization',
|
||||||
// icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
// estimatedTime: 'Variable',
|
estimatedTime: 'Variable',
|
||||||
// commonApprovers: [],
|
commonApprovers: [],
|
||||||
// suggestedSLA: 0,
|
suggestedSLA: 0,
|
||||||
// priority: 'medium',
|
priority: 'medium',
|
||||||
// fields: {}
|
fields: {}
|
||||||
// } as any
|
} as any
|
||||||
]
|
]
|
||||||
: adminTemplates;
|
: adminTemplates;
|
||||||
|
|
||||||
@ -111,7 +111,7 @@ export function TemplateSelectionStep({
|
|||||||
const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
|
const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
|
||||||
const isDisabled = isComingSoon;
|
const isDisabled = isComingSoon;
|
||||||
const isCategoryCard = template.id === 'admin-templates-category';
|
const isCategoryCard = template.id === 'admin-templates-category';
|
||||||
// const isCustomCard = template.id === 'custom';
|
const isCustomCard = template.id === 'custom';
|
||||||
const isSelected = selectedTemplate?.id === template.id;
|
const isSelected = selectedTemplate?.id === template.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export interface DocumentData {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
name: string;
|
name: string;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
size?: string;
|
size: string;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
uploadedBy?: string;
|
uploadedBy?: string;
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
@ -48,9 +48,7 @@ export function DocumentCard({
|
|||||||
{document.name}
|
{document.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
|
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
|
||||||
{document.size && <span>{document.size} • </span>}
|
{document.size} • Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)}
|
||||||
{document.uploadedBy && <span>Uploaded by {document.uploadedBy} on </span>}
|
|
||||||
{formatDateTime(document.uploadedAt)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { createContext, useContext, useEffect, useState, ReactNode, useRef } fro
|
|||||||
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
|
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
|
||||||
import { TokenManager, isTokenExpired } from '../utils/tokenManager';
|
import { TokenManager, isTokenExpired } from '../utils/tokenManager';
|
||||||
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
|
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
|
||||||
import { tanflowLogout } from '../services/tanflowAuth';
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@ -101,28 +100,18 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
|
if (urlParams.has('logout') || urlParams.has('okta_logged_out')) {
|
||||||
console.log('🚪 Logout parameter detected in URL, clearing all tokens');
|
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
// Clear auth provider flag and logout-related flags
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
sessionStorage.removeItem('tanflow_auth_state');
|
|
||||||
sessionStorage.removeItem('__logout_in_progress__');
|
|
||||||
sessionStorage.removeItem('__force_logout__');
|
|
||||||
sessionStorage.removeItem('tanflow_logged_out');
|
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
// Don't clear sessionStorage completely - we might need logout flags
|
sessionStorage.clear();
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// Clean URL but preserve logout flags if they exist (for prompt=login)
|
// Clean URL but preserve okta_logged_out flag if it exists (for prompt=login)
|
||||||
const cleanParams = new URLSearchParams();
|
const cleanParams = new URLSearchParams();
|
||||||
if (urlParams.has('okta_logged_out')) {
|
if (urlParams.has('okta_logged_out')) {
|
||||||
cleanParams.set('okta_logged_out', 'true');
|
cleanParams.set('okta_logged_out', 'true');
|
||||||
}
|
}
|
||||||
if (urlParams.has('tanflow_logged_out')) {
|
|
||||||
cleanParams.set('tanflow_logged_out', 'true');
|
|
||||||
}
|
|
||||||
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
|
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
|
||||||
window.history.replaceState({}, document.title, newUrl);
|
window.history.replaceState({}, document.title, newUrl);
|
||||||
return;
|
return;
|
||||||
@ -131,7 +120,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
|
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
|
||||||
// This is critical for production mode where we need to exchange code for tokens
|
// This is critical for production mode where we need to exchange code for tokens
|
||||||
// before we can verify session with server
|
// before we can verify session with server
|
||||||
if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') {
|
if (window.location.pathname === '/login/callback') {
|
||||||
// Don't check auth status here - let the callback handler do its job
|
// Don't check auth status here - let the callback handler do its job
|
||||||
// The callback handler will set isAuthenticated after successful token exchange
|
// The callback handler will set isAuthenticated after successful token exchange
|
||||||
return;
|
return;
|
||||||
@ -219,57 +208,24 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCallback = async () => {
|
const handleCallback = async () => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
// Check if this is a logout redirect (from Tanflow post-logout redirect)
|
|
||||||
// If it has logout parameters but no code, it's a logout redirect, not a login callback
|
|
||||||
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
|
|
||||||
// This is a logout redirect, not a login callback
|
|
||||||
// Redirect to home page - the mount useEffect will handle logout cleanup
|
|
||||||
console.log('🚪 Logout redirect detected in callback, redirecting to home');
|
|
||||||
// Extract the logout flags from current URL
|
|
||||||
const logoutFlags = new URLSearchParams();
|
|
||||||
if (urlParams.has('tanflow_logged_out')) logoutFlags.set('tanflow_logged_out', 'true');
|
|
||||||
if (urlParams.has('okta_logged_out')) logoutFlags.set('okta_logged_out', 'true');
|
|
||||||
if (urlParams.has('logout')) logoutFlags.set('logout', urlParams.get('logout') || Date.now().toString());
|
|
||||||
const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now();
|
|
||||||
window.location.replace(redirectUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as processed immediately to prevent duplicate calls
|
// Mark as processed immediately to prevent duplicate calls
|
||||||
callbackProcessedRef.current = true;
|
callbackProcessedRef.current = true;
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
const errorParam = urlParams.get('error');
|
const errorParam = urlParams.get('error');
|
||||||
|
|
||||||
// Clean URL immediately to prevent re-running on re-renders
|
// Clean URL immediately to prevent re-running on re-renders
|
||||||
window.history.replaceState({}, document.title, '/login/callback');
|
window.history.replaceState({}, document.title, '/login/callback');
|
||||||
|
|
||||||
// Detect provider from sessionStorage
|
|
||||||
const authProvider = sessionStorage.getItem('auth_provider');
|
|
||||||
|
|
||||||
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
|
|
||||||
if (authProvider === 'tanflow') {
|
|
||||||
// Clear the provider flag and let TanflowCallback handle it
|
|
||||||
// Reset ref so TanflowCallback can process
|
|
||||||
callbackProcessedRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle OKTA callback (default)
|
|
||||||
if (errorParam) {
|
if (errorParam) {
|
||||||
setError(new Error(`Authentication error: ${errorParam}`));
|
setError(new Error(`Authentication error: ${errorParam}`));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// Clear provider flag
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// Clear provider flag
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,9 +245,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Clear provider flag after successful authentication
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
|
|
||||||
// Clean URL after success
|
// Clean URL after success
|
||||||
window.history.replaceState({}, document.title, '/');
|
window.history.replaceState({}, document.title, '/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -299,8 +252,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setError(err);
|
setError(err);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
// Clear provider flag on error
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
// Reset ref on error so user can retry if needed
|
// Reset ref on error so user can retry if needed
|
||||||
callbackProcessedRef.current = false;
|
callbackProcessedRef.current = false;
|
||||||
} finally {
|
} finally {
|
||||||
@ -461,12 +412,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const scope = 'openid profile email';
|
const scope = 'openid profile email';
|
||||||
const state = Math.random().toString(36).substring(7);
|
const state = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
// Store provider type to identify OKTA callback
|
|
||||||
sessionStorage.setItem('auth_provider', 'okta');
|
|
||||||
|
|
||||||
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
|
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out');
|
||||||
|
|
||||||
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
||||||
`client_id=${clientId}&` +
|
`client_id=${clientId}&` +
|
||||||
@ -491,14 +439,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
// CRITICAL: Get id_token from TokenManager before clearing anything
|
// CRITICAL: Get id_token from TokenManager before clearing anything
|
||||||
// Needed for both Okta and Tanflow logout endpoints
|
// Okta logout endpoint works better with id_token_hint to properly end the session
|
||||||
const idToken = TokenManager.getIdToken();
|
// Note: Currently not used but kept for future Okta integration
|
||||||
|
void TokenManager.getIdToken();
|
||||||
// Detect which provider was used for login (check sessionStorage or user data)
|
|
||||||
// If auth_provider is set, use it; otherwise check if we have Tanflow id_token pattern
|
|
||||||
const authProvider = sessionStorage.getItem('auth_provider') ||
|
|
||||||
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
|
|
||||||
'okta'; // Default to OKTA if unknown
|
|
||||||
|
|
||||||
// Set logout flag to prevent auto-authentication after redirect
|
// Set logout flag to prevent auto-authentication after redirect
|
||||||
// This must be set BEFORE clearing storage so it survives
|
// This must be set BEFORE clearing storage so it survives
|
||||||
@ -516,58 +459,29 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
|
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
|
||||||
try {
|
try {
|
||||||
await logoutApi();
|
await logoutApi();
|
||||||
console.log('🚪 Backend logout API called successfully');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('🚪 Logout API error:', err);
|
console.error('🚪 Logout API error:', err);
|
||||||
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
||||||
// Continue with logout even if API call fails
|
// Continue with logout even if API call fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens
|
// Clear all authentication data EXCEPT the logout flags and id_token (we need it for Okta logout)
|
||||||
|
|
||||||
|
// Clear tokens but preserve logout flags
|
||||||
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
|
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
|
||||||
const forceLogout = sessionStorage.getItem('__force_logout__');
|
const forceLogout = sessionStorage.getItem('__force_logout__');
|
||||||
const storedAuthProvider = sessionStorage.getItem('auth_provider');
|
|
||||||
|
|
||||||
// Clear all tokens EXCEPT id_token (we need it for provider logout)
|
// Use TokenManager.clearAll() but then restore logout flags
|
||||||
// Note: We'll clear id_token after provider logout
|
|
||||||
// Clear tokens (but we'll restore id_token if needed)
|
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
|
|
||||||
// Restore logout flags and id_token immediately after clearAll
|
// Restore logout flags immediately after clearAll
|
||||||
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
||||||
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
||||||
if (idToken) {
|
|
||||||
TokenManager.setIdToken(idToken); // Restore id_token for provider logout
|
|
||||||
}
|
|
||||||
if (storedAuthProvider) {
|
|
||||||
sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay to ensure sessionStorage is written before redirect
|
// Small delay to ensure sessionStorage is written before redirect
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Handle provider-specific logout
|
// Redirect directly to login page with flags
|
||||||
if (authProvider === 'tanflow' && idToken) {
|
|
||||||
console.log('🚪 Initiating Tanflow logout...');
|
|
||||||
// Tanflow logout - redirect to Tanflow logout endpoint
|
|
||||||
// This will clear Tanflow session and redirect back to our app
|
|
||||||
try {
|
|
||||||
tanflowLogout(idToken);
|
|
||||||
// tanflowLogout will redirect, so we don't need to do anything else here
|
|
||||||
return;
|
|
||||||
} catch (tanflowLogoutError) {
|
|
||||||
console.error('🚪 Tanflow logout error:', tanflowLogoutError);
|
|
||||||
// Fall through to default logout flow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
|
|
||||||
console.log('🚪 Using OKTA logout flow or fallback');
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
// Clear id_token now since we're not using provider logout
|
|
||||||
if (idToken) {
|
|
||||||
TokenManager.clearAll(); // Clear id_token too
|
|
||||||
}
|
|
||||||
// The okta_logged_out flag will trigger prompt=login in the login() function
|
// The okta_logged_out flag will trigger prompt=login in the login() function
|
||||||
// This forces re-authentication even if Okta session still exists
|
// This forces re-authentication even if Okta session still exists
|
||||||
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
|
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
|
||||||
|
|||||||
@ -1,172 +0,0 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Filter, Search, SortAsc, SortDesc, Flame, Target, CheckCircle, XCircle, X } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ClosedRequestsFiltersProps {
|
|
||||||
searchTerm: string;
|
|
||||||
priorityFilter: string;
|
|
||||||
statusFilter: string;
|
|
||||||
templateTypeFilter: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
|
||||||
sortOrder: 'asc' | 'desc';
|
|
||||||
activeFiltersCount: number;
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onPriorityChange: (value: string) => void;
|
|
||||||
onStatusChange: (value: string) => void;
|
|
||||||
onTemplateTypeChange: (value: string) => void;
|
|
||||||
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
|
|
||||||
onSortOrderChange: () => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard Closed Requests Filters Component
|
|
||||||
*
|
|
||||||
* Used for regular users (non-dealers).
|
|
||||||
* Includes: Search, Priority, Status (Closure Type), Template Type, and Sort filters.
|
|
||||||
*/
|
|
||||||
export function StandardClosedRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
priorityFilter,
|
|
||||||
statusFilter,
|
|
||||||
// templateTypeFilter,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
activeFiltersCount,
|
|
||||||
onSearchChange,
|
|
||||||
onPriorityChange,
|
|
||||||
onStatusChange,
|
|
||||||
// onTemplateTypeChange,
|
|
||||||
onSortByChange,
|
|
||||||
onSortOrderChange,
|
|
||||||
onClearFilters,
|
|
||||||
}: ClosedRequestsFiltersProps) {
|
|
||||||
return (
|
|
||||||
<Card className="shadow-lg border-0" data-testid="closed-requests-filters">
|
|
||||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
|
|
||||||
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<span className="text-blue-600 font-medium">
|
|
||||||
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
|
||||||
data-testid="closed-requests-clear-filters"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="text-xs sm:text-sm">Clear</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests, IDs..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white transition-colors"
|
|
||||||
data-testid="closed-requests-search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={onPriorityChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-priority-filter">
|
|
||||||
<SelectValue placeholder="All Priorities" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priorities</SelectItem>
|
|
||||||
<SelectItem value="express">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Flame className="w-4 h-4 text-orange-600" />
|
|
||||||
<span>Express</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="standard">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Target className="w-4 h-4 text-blue-600" />
|
|
||||||
<span>Standard</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-status-filter">
|
|
||||||
<SelectValue placeholder="Closure Type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Closures</SelectItem>
|
|
||||||
<SelectItem value="approved">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
||||||
<span>Closed After Approval</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="rejected">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<XCircle className="w-4 h-4 text-red-600" />
|
|
||||||
<span>Closed After Rejection</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{/*
|
|
||||||
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select> */}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">
|
|
||||||
<SelectValue placeholder="Sort by" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="due">Due Date</SelectItem>
|
|
||||||
<SelectItem value="created">Date Created</SelectItem>
|
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onSortOrderChange}
|
|
||||||
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
|
||||||
data-testid="closed-requests-sort-order"
|
|
||||||
>
|
|
||||||
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Filter, Search, SortAsc, SortDesc, X, Flame, Target } from 'lucide-react';
|
|
||||||
|
|
||||||
interface RequestsFiltersProps {
|
|
||||||
searchTerm: string;
|
|
||||||
statusFilter: string;
|
|
||||||
priorityFilter: string;
|
|
||||||
templateTypeFilter: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority' | 'sla';
|
|
||||||
sortOrder: 'asc' | 'desc';
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onStatusFilterChange: (value: string) => void;
|
|
||||||
onPriorityFilterChange: (value: string) => void;
|
|
||||||
onTemplateTypeFilterChange: (value: string) => void;
|
|
||||||
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
|
|
||||||
onSortOrderChange: (value: 'asc' | 'desc') => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
activeFiltersCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard Requests Filters Component
|
|
||||||
*
|
|
||||||
* Used for regular users (non-dealers).
|
|
||||||
* Includes: Search, Status, Priority, Template Type, and Sort filters.
|
|
||||||
*/
|
|
||||||
export function StandardRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
statusFilter,
|
|
||||||
priorityFilter,
|
|
||||||
// templateTypeFilter,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
onSearchChange,
|
|
||||||
onStatusFilterChange,
|
|
||||||
onPriorityFilterChange,
|
|
||||||
// onTemplateTypeFilterChange,
|
|
||||||
onSortByChange,
|
|
||||||
onSortOrderChange,
|
|
||||||
onClearFilters,
|
|
||||||
activeFiltersCount,
|
|
||||||
}: RequestsFiltersProps) {
|
|
||||||
return (
|
|
||||||
<Card className="shadow-lg border-0">
|
|
||||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
|
|
||||||
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<span className="text-blue-600 font-medium">
|
|
||||||
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="text-xs sm:text-sm">Clear</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
|
||||||
{/* Standard filters - Search, Status, Priority, Template Type, and Sort */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests, IDs..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={onPriorityFilterChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Priorities" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priorities</SelectItem>
|
|
||||||
<SelectItem value="express">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Flame className="w-4 h-4 text-orange-600" />
|
|
||||||
<span>Express</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="standard">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Target className="w-4 h-4 text-blue-600" />
|
|
||||||
<span>Standard</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Statuses" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending (In Approval)</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select> */}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="Sort by" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="due">Due Date</SelectItem>
|
|
||||||
<SelectItem value="created">Date Created</SelectItem>
|
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
|
||||||
<SelectItem value="sla">SLA Progress</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
|
|
||||||
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
|
||||||
>
|
|
||||||
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,457 +0,0 @@
|
|||||||
/**
|
|
||||||
* Standard User All Requests Filters Component
|
|
||||||
*
|
|
||||||
* Full filters for regular users (non-dealers).
|
|
||||||
* Includes: Search, Status, Priority, Template Type, Department, SLA Compliance,
|
|
||||||
* Initiator, Approver, and Date Range filters.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import type { DateRange } from '@/services/dashboard.service';
|
|
||||||
import { CustomDatePicker } from '@/components/ui/date-picker';
|
|
||||||
|
|
||||||
interface StandardUserAllRequestsFiltersProps {
|
|
||||||
// Filters
|
|
||||||
searchTerm: string;
|
|
||||||
statusFilter: string;
|
|
||||||
priorityFilter: string;
|
|
||||||
templateTypeFilter: string;
|
|
||||||
departmentFilter: string;
|
|
||||||
slaComplianceFilter: string;
|
|
||||||
initiatorFilter: string;
|
|
||||||
approverFilter: string;
|
|
||||||
approverFilterType: 'current' | 'any';
|
|
||||||
dateRange: DateRange;
|
|
||||||
customStartDate?: Date;
|
|
||||||
customEndDate?: Date;
|
|
||||||
showCustomDatePicker: boolean;
|
|
||||||
|
|
||||||
// Departments
|
|
||||||
departments: string[];
|
|
||||||
loadingDepartments: boolean;
|
|
||||||
|
|
||||||
// State for user search
|
|
||||||
initiatorSearch: {
|
|
||||||
selectedUser: { userId: string; email: string; displayName?: string } | null;
|
|
||||||
searchQuery: string;
|
|
||||||
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
|
|
||||||
showResults: boolean;
|
|
||||||
handleSearch: (query: string) => void;
|
|
||||||
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
|
|
||||||
handleClear: () => void;
|
|
||||||
setShowResults: (show: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
approverSearch: {
|
|
||||||
selectedUser: { userId: string; email: string; displayName?: string } | null;
|
|
||||||
searchQuery: string;
|
|
||||||
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
|
|
||||||
showResults: boolean;
|
|
||||||
handleSearch: (query: string) => void;
|
|
||||||
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
|
|
||||||
handleClear: () => void;
|
|
||||||
setShowResults: (show: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onStatusChange: (value: string) => void;
|
|
||||||
onPriorityChange: (value: string) => void;
|
|
||||||
onTemplateTypeChange: (value: string) => void;
|
|
||||||
onDepartmentChange: (value: string) => void;
|
|
||||||
onSlaComplianceChange: (value: string) => void;
|
|
||||||
onInitiatorChange?: (value: string) => void;
|
|
||||||
onApproverChange?: (value: string) => void;
|
|
||||||
onApproverTypeChange?: (value: 'current' | 'any') => void;
|
|
||||||
onDateRangeChange: (value: DateRange) => void;
|
|
||||||
onCustomStartDateChange?: (date: Date | undefined) => void;
|
|
||||||
onCustomEndDateChange?: (date: Date | undefined) => void;
|
|
||||||
onShowCustomDatePickerChange?: (show: boolean) => void;
|
|
||||||
onApplyCustomDate?: () => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
hasActiveFilters: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StandardUserAllRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
statusFilter,
|
|
||||||
priorityFilter,
|
|
||||||
// templateTypeFilter,
|
|
||||||
departmentFilter,
|
|
||||||
slaComplianceFilter,
|
|
||||||
initiatorFilter: _initiatorFilter,
|
|
||||||
approverFilter,
|
|
||||||
approverFilterType,
|
|
||||||
dateRange,
|
|
||||||
customStartDate,
|
|
||||||
customEndDate,
|
|
||||||
showCustomDatePicker,
|
|
||||||
departments,
|
|
||||||
loadingDepartments,
|
|
||||||
initiatorSearch,
|
|
||||||
approverSearch,
|
|
||||||
onSearchChange,
|
|
||||||
onStatusChange,
|
|
||||||
onPriorityChange,
|
|
||||||
// onTemplateTypeChange,
|
|
||||||
onDepartmentChange,
|
|
||||||
onSlaComplianceChange,
|
|
||||||
onInitiatorChange: _onInitiatorChange,
|
|
||||||
onApproverChange: _onApproverChange,
|
|
||||||
onApproverTypeChange,
|
|
||||||
onDateRangeChange,
|
|
||||||
onCustomStartDateChange,
|
|
||||||
onCustomEndDateChange,
|
|
||||||
onShowCustomDatePickerChange,
|
|
||||||
onApplyCustomDate,
|
|
||||||
onClearFilters,
|
|
||||||
hasActiveFilters,
|
|
||||||
}: StandardUserAllRequestsFiltersProps) {
|
|
||||||
return (
|
|
||||||
<Card className="border-gray-200 shadow-md" data-testid="user-all-requests-filters">
|
|
||||||
<CardContent className="p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="w-5 h-5 text-muted-foreground" />
|
|
||||||
<h3 className="font-semibold text-gray-900">Advanced Filters</h3>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Primary Filters */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
|
||||||
<div className="relative md:col-span-3 lg:col-span-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-10 h-10"
|
|
||||||
data-testid="search-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="status-filter">
|
|
||||||
<SelectValue placeholder="All Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="paused">Paused</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={onPriorityChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="priority-filter">
|
|
||||||
<SelectValue placeholder="All Priority" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priority</SelectItem>
|
|
||||||
<SelectItem value="express">Express</SelectItem>
|
|
||||||
<SelectItem value="standard">Standard</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Custom</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select> */}
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={departmentFilter}
|
|
||||||
onValueChange={onDepartmentChange}
|
|
||||||
disabled={loadingDepartments || departments.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10" data-testid="department-filter">
|
|
||||||
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Departments</SelectItem>
|
|
||||||
{departments.map((dept) => (
|
|
||||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={slaComplianceFilter} onValueChange={onSlaComplianceChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
|
|
||||||
<SelectValue placeholder="All SLA Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All SLA Status</SelectItem>
|
|
||||||
<SelectItem value="compliant">Compliant</SelectItem>
|
|
||||||
<SelectItem value="on-track">On Track</SelectItem>
|
|
||||||
<SelectItem value="approaching">Approaching</SelectItem>
|
|
||||||
<SelectItem value="critical">Critical</SelectItem>
|
|
||||||
<SelectItem value="breached">Breached</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Filters - Initiator and Approver */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
|
||||||
{/* Initiator Filter */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
|
|
||||||
<div className="relative">
|
|
||||||
{initiatorSearch.selectedUser ? (
|
|
||||||
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
|
|
||||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
|
||||||
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="Search initiator..."
|
|
||||||
value={initiatorSearch.searchQuery}
|
|
||||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (initiatorSearch.searchResults.length > 0) {
|
|
||||||
initiatorSearch.setShowResults(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
|
|
||||||
className="h-10"
|
|
||||||
data-testid="initiator-search-input"
|
|
||||||
/>
|
|
||||||
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{initiatorSearch.searchResults.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => initiatorSearch.handleSelect(user)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{user.displayName || user.email}
|
|
||||||
</span>
|
|
||||||
{user.displayName && (
|
|
||||||
<span className="text-xs text-gray-500">{user.email}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Approver Filter */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Label className="text-sm font-medium text-gray-700">Approver</Label>
|
|
||||||
{approverFilter !== 'all' && onApproverTypeChange && (
|
|
||||||
<Select
|
|
||||||
value={approverFilterType}
|
|
||||||
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-32 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="current">Current Only</SelectItem>
|
|
||||||
<SelectItem value="any">Any Approver</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
{approverSearch.selectedUser ? (
|
|
||||||
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
|
|
||||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
|
||||||
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="Search approver..."
|
|
||||||
value={approverSearch.searchQuery}
|
|
||||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (approverSearch.searchResults.length > 0) {
|
|
||||||
approverSearch.setShowResults(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
|
|
||||||
className="h-10"
|
|
||||||
data-testid="approver-search-input"
|
|
||||||
/>
|
|
||||||
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{approverSearch.searchResults.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => approverSearch.handleSelect(user)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{user.displayName || user.email}
|
|
||||||
</span>
|
|
||||||
{user.displayName && (
|
|
||||||
<span className="text-xs text-gray-500">{user.email}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Range Filter */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
|
|
||||||
<SelectTrigger className="w-[160px] h-10">
|
|
||||||
<SelectValue placeholder="Date Range" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Time</SelectItem>
|
|
||||||
<SelectItem value="today">Today</SelectItem>
|
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
|
||||||
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
|
||||||
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
|
||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{dateRange === 'custom' && (
|
|
||||||
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
{customStartDate && customEndDate
|
|
||||||
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
|
|
||||||
: 'Select dates'}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-4" align="start">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="start-date">Start Date</Label>
|
|
||||||
<CustomDatePicker
|
|
||||||
value={customStartDate || null}
|
|
||||||
onChange={(dateStr: string | null) => {
|
|
||||||
const date = dateStr ? new Date(dateStr) : undefined;
|
|
||||||
if (date) {
|
|
||||||
onCustomStartDateChange?.(date);
|
|
||||||
if (customEndDate && date > customEndDate) {
|
|
||||||
onCustomEndDateChange?.(date);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onCustomStartDateChange?.(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maxDate={new Date()}
|
|
||||||
placeholderText="dd/mm/yyyy"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="end-date">End Date</Label>
|
|
||||||
<CustomDatePicker
|
|
||||||
value={customEndDate || null}
|
|
||||||
onChange={(dateStr: string | null) => {
|
|
||||||
const date = dateStr ? new Date(dateStr) : undefined;
|
|
||||||
if (date) {
|
|
||||||
onCustomEndDateChange?.(date);
|
|
||||||
if (customStartDate && date < customStartDate) {
|
|
||||||
onCustomStartDateChange?.(date);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onCustomEndDateChange?.(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
minDate={customStartDate || undefined}
|
|
||||||
maxDate={new Date()}
|
|
||||||
placeholderText="dd/mm/yyyy"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-2 border-t">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={onApplyCustomDate}
|
|
||||||
disabled={!customStartDate || !customEndDate}
|
|
||||||
className="flex-1 bg-re-green hover:bg-re-green/90"
|
|
||||||
>
|
|
||||||
Apply Range
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
onShowCustomDatePickerChange?.(false);
|
|
||||||
onCustomStartDateChange?.(undefined);
|
|
||||||
onCustomEndDateChange?.(undefined);
|
|
||||||
onDateRangeChange('month');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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,32 +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';
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
export { StandardRequestsFilters } from './components/RequestsFilters';
|
|
||||||
export { StandardClosedRequestsFilters } from './components/ClosedRequestsFilters';
|
|
||||||
export { StandardUserAllRequestsFilters } from './components/UserAllRequestsFilters';
|
|
||||||
|
|
||||||
// Re-export types
|
|
||||||
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
|
||||||
@ -1,713 +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';
|
|
||||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
|
||||||
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
|
||||||
|
|
||||||
// Custom Request Components (import from index to get properly aliased exports)
|
|
||||||
import { CustomOverviewTab, CustomWorkflowTab } from '../index';
|
|
||||||
|
|
||||||
// Shared Components (from src/shared/)
|
|
||||||
import { SharedComponents } from '@/shared/components';
|
|
||||||
const { DocumentsTab, ActivityTab, WorkNotesTab, SummaryTab, RequestDetailHeader, QuickActionsSidebar, RequestDetailModals } = SharedComponents;
|
|
||||||
|
|
||||||
// Other components
|
|
||||||
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
|
||||||
import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
|
||||||
import { PauseModal } from '@/components/workflow/PauseModal';
|
|
||||||
import { ResumeModal } from '@/components/workflow/ResumeModal';
|
|
||||||
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error Boundary Component
|
|
||||||
*/
|
|
||||||
class RequestDetailErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: Error | null }> {
|
|
||||||
constructor(props: { children: ReactNode }) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error) {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error('Custom RequestDetail Error:', error, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
|
|
||||||
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
|
|
||||||
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
|
||||||
<h2 className="text-2xl font-bold mb-2">Error Loading Request</h2>
|
|
||||||
<p className="text-gray-600 mb-4">{this.state.error?.message || 'An unexpected error occurred'}</p>
|
|
||||||
<Button onClick={() => window.location.reload()} className="mr-2">
|
|
||||||
Reload Page
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => window.history.back()}>
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom RequestDetailInner Component
|
|
||||||
*/
|
|
||||||
function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests = [] }: RequestDetailProps) {
|
|
||||||
const params = useParams<{ requestId: string }>();
|
|
||||||
const requestIdentifier = params.requestId || propRequestId || '';
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const initialTab = urlParams.get('tab') || 'overview';
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(initialTab);
|
|
||||||
const [showShareSummaryModal, setShowShareSummaryModal] = useState(false);
|
|
||||||
const [summaryId, setSummaryId] = useState<string | null>(null);
|
|
||||||
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
|
|
||||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
|
||||||
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
|
|
||||||
const [showPauseModal, setShowPauseModal] = useState(false);
|
|
||||||
const [showResumeModal, setShowResumeModal] = useState(false);
|
|
||||||
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
|
||||||
const [systemPolicy, setSystemPolicy] = useState<{
|
|
||||||
maxApprovalLevels: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
allowSpectators: boolean;
|
|
||||||
maxSpectators: number;
|
|
||||||
}>({
|
|
||||||
maxApprovalLevels: 10,
|
|
||||||
maxParticipants: 50,
|
|
||||||
allowSpectators: true,
|
|
||||||
maxSpectators: 20
|
|
||||||
});
|
|
||||||
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
|
||||||
open: boolean;
|
|
||||||
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
|
||||||
}>({
|
|
||||||
open: false,
|
|
||||||
violations: []
|
|
||||||
});
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
// Custom hooks
|
|
||||||
const {
|
|
||||||
request,
|
|
||||||
apiRequest,
|
|
||||||
loading: requestLoading,
|
|
||||||
refreshing,
|
|
||||||
refreshDetails,
|
|
||||||
currentApprovalLevel,
|
|
||||||
isSpectator,
|
|
||||||
isInitiator,
|
|
||||||
existingParticipants,
|
|
||||||
accessDenied,
|
|
||||||
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
|
|
||||||
|
|
||||||
const {
|
|
||||||
mergedMessages,
|
|
||||||
unreadWorkNotes,
|
|
||||||
workNoteAttachments,
|
|
||||||
setWorkNoteAttachments,
|
|
||||||
} = useRequestSocket(requestIdentifier, apiRequest, activeTab, user);
|
|
||||||
|
|
||||||
const {
|
|
||||||
uploadingDocument,
|
|
||||||
triggerFileInput,
|
|
||||||
previewDocument,
|
|
||||||
setPreviewDocument,
|
|
||||||
documentPolicy,
|
|
||||||
documentError,
|
|
||||||
setDocumentError,
|
|
||||||
} = useDocumentUpload(apiRequest, refreshDetails);
|
|
||||||
|
|
||||||
const {
|
|
||||||
showApproveModal,
|
|
||||||
setShowApproveModal,
|
|
||||||
showRejectModal,
|
|
||||||
setShowRejectModal,
|
|
||||||
showAddApproverModal,
|
|
||||||
setShowAddApproverModal,
|
|
||||||
showAddSpectatorModal,
|
|
||||||
setShowAddSpectatorModal,
|
|
||||||
showSkipApproverModal,
|
|
||||||
setShowSkipApproverModal,
|
|
||||||
showActionStatusModal,
|
|
||||||
setShowActionStatusModal,
|
|
||||||
skipApproverData,
|
|
||||||
setSkipApproverData,
|
|
||||||
actionStatus,
|
|
||||||
setActionStatus,
|
|
||||||
handleApproveConfirm,
|
|
||||||
handleRejectConfirm,
|
|
||||||
handleAddApprover,
|
|
||||||
handleSkipApprover,
|
|
||||||
handleAddSpectator,
|
|
||||||
} = useModalManager(requestIdentifier, currentApprovalLevel, refreshDetails);
|
|
||||||
|
|
||||||
const {
|
|
||||||
conclusionRemark,
|
|
||||||
setConclusionRemark,
|
|
||||||
conclusionLoading,
|
|
||||||
conclusionSubmitting,
|
|
||||||
aiGenerated,
|
|
||||||
handleGenerateConclusion,
|
|
||||||
handleFinalizeConclusion,
|
|
||||||
generationAttempts,
|
|
||||||
generationFailed,
|
|
||||||
maxAttemptsReached,
|
|
||||||
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
|
|
||||||
|
|
||||||
// Load system policy on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadSystemPolicy = async () => {
|
|
||||||
try {
|
|
||||||
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
|
||||||
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
|
||||||
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
|
||||||
const configMap: Record<string, string> = {};
|
|
||||||
allConfigs.forEach((c: AdminConfiguration) => {
|
|
||||||
configMap[c.configKey] = c.configValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
setSystemPolicy({
|
|
||||||
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
|
|
||||||
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
|
|
||||||
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
|
|
||||||
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load system policy:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSystemPolicy();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-switch tab when URL query parameter changes
|
|
||||||
useEffect(() => {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const tabParam = urlParams.get('tab');
|
|
||||||
if (tabParam) {
|
|
||||||
setActiveTab(tabParam);
|
|
||||||
}
|
|
||||||
}, [requestIdentifier]);
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
refreshDetails();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pause handlers
|
|
||||||
const handlePause = () => {
|
|
||||||
setShowPauseModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResume = () => {
|
|
||||||
setShowResumeModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResumeSuccess = async () => {
|
|
||||||
await refreshDetails();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRetrigger = () => {
|
|
||||||
setShowRetriggerModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePauseSuccess = async () => {
|
|
||||||
await refreshDetails();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRetriggerSuccess = async () => {
|
|
||||||
await refreshDetails();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShareSummary = async () => {
|
|
||||||
if (!apiRequest?.requestId) {
|
|
||||||
toast.error('Request ID not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!summaryId) {
|
|
||||||
toast.error('Summary not available. Please ensure the request is closed and the summary has been generated.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowShareSummaryModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
|
|
||||||
const isClosed = request?.status === 'closed';
|
|
||||||
|
|
||||||
// Fetch summary details if request is closed
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSummaryDetails = async () => {
|
|
||||||
if (!isClosed || !apiRequest?.requestId) {
|
|
||||||
setSummaryDetails(null);
|
|
||||||
setSummaryId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoadingSummary(true);
|
|
||||||
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
|
||||||
|
|
||||||
if (summary?.summaryId) {
|
|
||||||
setSummaryId(summary.summaryId);
|
|
||||||
try {
|
|
||||||
const details = await getSummaryDetails(summary.summaryId);
|
|
||||||
setSummaryDetails(details);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to fetch summary details:', error);
|
|
||||||
setSummaryDetails(null);
|
|
||||||
setSummaryId(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSummaryDetails(null);
|
|
||||||
setSummaryId(null);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setSummaryDetails(null);
|
|
||||||
setSummaryId(null);
|
|
||||||
} finally {
|
|
||||||
setLoadingSummary(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSummaryDetails();
|
|
||||||
}, [isClosed, apiRequest?.requestId]);
|
|
||||||
|
|
||||||
// Get current levels for WorkNotesTab
|
|
||||||
const currentLevels = (request?.approvalFlow || [])
|
|
||||||
.filter((flow: any) => flow && typeof flow.step === 'number')
|
|
||||||
.map((flow: any) => ({
|
|
||||||
levelNumber: flow.step || 0,
|
|
||||||
approverName: flow.approver || 'Unknown',
|
|
||||||
status: flow.status || 'pending',
|
|
||||||
tatHours: flow.tatHours || 24,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (requestLoading && !request && !apiRequest) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state">
|
|
||||||
<div className="text-center">
|
|
||||||
<RefreshCw className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
|
|
||||||
<p className="text-gray-600">Loading custom request details...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access Denied state
|
|
||||||
if (accessDenied?.denied) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="access-denied-state">
|
|
||||||
<div className="max-w-lg w-full bg-white rounded-2xl shadow-xl p-8 text-center">
|
|
||||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<ShieldX className="w-10 h-10 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">Access Denied</h2>
|
|
||||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
|
||||||
{accessDenied.message}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 justify-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onBack || (() => window.history.back())}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Go to Dashboard
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not Found state
|
|
||||||
if (!request) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="not-found-state">
|
|
||||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
|
|
||||||
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<FileText className="w-10 h-10 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">Custom Request Not Found</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
The custom request you're looking for doesn't exist or may have been deleted.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 justify-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onBack || (() => window.history.back())}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Go to Dashboard
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="min-h-screen bg-gray-50" data-testid="custom-request-detail-page">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
{/* Header Section */}
|
|
||||||
<RequestDetailHeader
|
|
||||||
request={request}
|
|
||||||
refreshing={refreshing}
|
|
||||||
onBack={onBack || (() => window.history.back())}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
onShareSummary={handleShareSummary}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
// Custom module: Business logic for preparing SLA data
|
|
||||||
slaData={request?.summary?.sla || request?.sla || null}
|
|
||||||
isPaused={request?.pauseInfo?.isPaused || false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="custom-request-detail-tabs">
|
|
||||||
<div className="mb-4 sm:mb-6">
|
|
||||||
<TabsList className="grid grid-cols-3 sm:grid-cols-6 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
|
|
||||||
<TabsTrigger
|
|
||||||
value="overview"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-overview"
|
|
||||||
>
|
|
||||||
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Overview</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
{isClosed && summaryDetails && (
|
|
||||||
<TabsTrigger
|
|
||||||
value="summary"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-summary"
|
|
||||||
>
|
|
||||||
<FileCheck className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Summary</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
<TabsTrigger
|
|
||||||
value="workflow"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-workflow"
|
|
||||||
>
|
|
||||||
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Workflow</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="documents"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-documents"
|
|
||||||
>
|
|
||||||
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Docs</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="activity"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 col-span-1 sm:col-span-1"
|
|
||||||
data-testid="tab-activity"
|
|
||||||
>
|
|
||||||
<Activity className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Activity</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="worknotes"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 relative col-span-2 sm:col-span-1"
|
|
||||||
data-testid="tab-worknotes"
|
|
||||||
>
|
|
||||||
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Work Notes</span>
|
|
||||||
{unreadWorkNotes > 0 && (
|
|
||||||
<Badge
|
|
||||||
className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-[10px] flex items-center justify-center p-0"
|
|
||||||
data-testid="worknotes-unread-badge"
|
|
||||||
>
|
|
||||||
{unreadWorkNotes > 9 ? '9+' : unreadWorkNotes}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Layout */}
|
|
||||||
<div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
|
|
||||||
{/* Left Column: Tab content */}
|
|
||||||
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
|
|
||||||
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
|
|
||||||
<CustomOverviewTab
|
|
||||||
request={request}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
needsClosure={needsClosure}
|
|
||||||
conclusionRemark={conclusionRemark}
|
|
||||||
setConclusionRemark={setConclusionRemark}
|
|
||||||
conclusionLoading={conclusionLoading}
|
|
||||||
conclusionSubmitting={conclusionSubmitting}
|
|
||||||
aiGenerated={aiGenerated}
|
|
||||||
handleGenerateConclusion={handleGenerateConclusion}
|
|
||||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
|
||||||
onPause={handlePause}
|
|
||||||
onResume={handleResume}
|
|
||||||
onRetrigger={handleRetrigger}
|
|
||||||
currentUserIsApprover={!!currentApprovalLevel}
|
|
||||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
|
||||||
currentUserId={(user as any)?.userId}
|
|
||||||
generationAttempts={generationAttempts}
|
|
||||||
generationFailed={generationFailed}
|
|
||||||
maxAttemptsReached={maxAttemptsReached}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{isClosed && (
|
|
||||||
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
|
|
||||||
<SummaryTab
|
|
||||||
summary={summaryDetails}
|
|
||||||
loading={loadingSummary}
|
|
||||||
onShare={handleShareSummary}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TabsContent value="workflow" className="mt-0">
|
|
||||||
<CustomWorkflowTab
|
|
||||||
request={request}
|
|
||||||
user={user}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
onSkipApprover={(data) => {
|
|
||||||
if (!data.levelId) {
|
|
||||||
alert('Level ID not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSkipApproverData(data);
|
|
||||||
setShowSkipApproverModal(true);
|
|
||||||
}}
|
|
||||||
onRefresh={refreshDetails}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="documents" className="mt-0">
|
|
||||||
<DocumentsTab
|
|
||||||
request={request}
|
|
||||||
workNoteAttachments={workNoteAttachments}
|
|
||||||
uploadingDocument={uploadingDocument}
|
|
||||||
documentPolicy={documentPolicy}
|
|
||||||
triggerFileInput={triggerFileInput}
|
|
||||||
setPreviewDocument={setPreviewDocument}
|
|
||||||
downloadDocument={downloadDocument}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="activity" className="mt-0">
|
|
||||||
<ActivityTab request={request} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="worknotes" className="mt-0" forceMount={true} hidden={activeTab !== 'worknotes'}>
|
|
||||||
<WorkNotesTab
|
|
||||||
requestId={requestIdentifier}
|
|
||||||
requestTitle={request.title}
|
|
||||||
mergedMessages={mergedMessages}
|
|
||||||
setWorkNoteAttachments={setWorkNoteAttachments}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
isSpectator={isSpectator}
|
|
||||||
currentLevels={currentLevels}
|
|
||||||
onAddApprover={handleAddApprover}
|
|
||||||
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
|
||||||
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Quick Actions Sidebar */}
|
|
||||||
{activeTab !== 'worknotes' && (
|
|
||||||
<QuickActionsSidebar
|
|
||||||
request={request}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
isSpectator={isSpectator}
|
|
||||||
currentApprovalLevel={currentApprovalLevel}
|
|
||||||
onAddApprover={() => setShowAddApproverModal(true)}
|
|
||||||
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
|
||||||
onApprove={() => setShowApproveModal(true)}
|
|
||||||
onReject={() => setShowRejectModal(true)}
|
|
||||||
onPause={handlePause}
|
|
||||||
onResume={handleResume}
|
|
||||||
onRetrigger={handleRetrigger}
|
|
||||||
summaryId={summaryId}
|
|
||||||
refreshTrigger={sharedRecipientsRefreshTrigger}
|
|
||||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
|
||||||
currentUserId={(user as any)?.userId}
|
|
||||||
apiRequest={apiRequest}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share Summary Modal */}
|
|
||||||
{showShareSummaryModal && summaryId && (
|
|
||||||
<ShareSummaryModal
|
|
||||||
isOpen={showShareSummaryModal}
|
|
||||||
onClose={() => setShowShareSummaryModal(false)}
|
|
||||||
summaryId={summaryId}
|
|
||||||
requestTitle={request?.title || 'N/A'}
|
|
||||||
onSuccess={() => {
|
|
||||||
refreshDetails();
|
|
||||||
setSharedRecipientsRefreshTrigger(prev => prev + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pause Modals */}
|
|
||||||
{showPauseModal && apiRequest?.requestId && (
|
|
||||||
<PauseModal
|
|
||||||
isOpen={showPauseModal}
|
|
||||||
onClose={() => setShowPauseModal(false)}
|
|
||||||
requestId={apiRequest.requestId}
|
|
||||||
levelId={currentApprovalLevel?.levelId || null}
|
|
||||||
onSuccess={handlePauseSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showResumeModal && apiRequest?.requestId && (
|
|
||||||
<ResumeModal
|
|
||||||
isOpen={showResumeModal}
|
|
||||||
onClose={() => setShowResumeModal(false)}
|
|
||||||
requestId={apiRequest.requestId}
|
|
||||||
onSuccess={handleResumeSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showRetriggerModal && apiRequest?.requestId && (
|
|
||||||
<RetriggerPauseModal
|
|
||||||
isOpen={showRetriggerModal}
|
|
||||||
onClose={() => setShowRetriggerModal(false)}
|
|
||||||
requestId={apiRequest.requestId}
|
|
||||||
approverName={request?.pauseInfo?.pausedBy?.name}
|
|
||||||
onSuccess={handleRetriggerSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<RequestDetailModals
|
|
||||||
showApproveModal={showApproveModal}
|
|
||||||
showRejectModal={showRejectModal}
|
|
||||||
showAddApproverModal={showAddApproverModal}
|
|
||||||
showAddSpectatorModal={showAddSpectatorModal}
|
|
||||||
showSkipApproverModal={showSkipApproverModal}
|
|
||||||
showActionStatusModal={showActionStatusModal}
|
|
||||||
previewDocument={previewDocument}
|
|
||||||
documentError={documentError}
|
|
||||||
request={request}
|
|
||||||
skipApproverData={skipApproverData}
|
|
||||||
actionStatus={actionStatus}
|
|
||||||
existingParticipants={existingParticipants}
|
|
||||||
currentLevels={currentLevels}
|
|
||||||
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
|
||||||
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
|
||||||
setShowApproveModal={setShowApproveModal}
|
|
||||||
setShowRejectModal={setShowRejectModal}
|
|
||||||
setShowAddApproverModal={setShowAddApproverModal}
|
|
||||||
setShowAddSpectatorModal={setShowAddSpectatorModal}
|
|
||||||
setShowSkipApproverModal={setShowSkipApproverModal}
|
|
||||||
setShowActionStatusModal={setShowActionStatusModal}
|
|
||||||
setPreviewDocument={setPreviewDocument}
|
|
||||||
setDocumentError={setDocumentError}
|
|
||||||
setSkipApproverData={setSkipApproverData}
|
|
||||||
setActionStatus={setActionStatus}
|
|
||||||
handleApproveConfirm={handleApproveConfirm}
|
|
||||||
handleRejectConfirm={handleRejectConfirm}
|
|
||||||
handleAddApprover={handleAddApprover}
|
|
||||||
handleAddSpectator={handleAddSpectator}
|
|
||||||
handleSkipApprover={handleSkipApprover}
|
|
||||||
downloadDocument={downloadDocument}
|
|
||||||
documentPolicy={documentPolicy}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Policy Violation Modal */}
|
|
||||||
<PolicyViolationModal
|
|
||||||
open={policyViolationModal.open}
|
|
||||||
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
|
|
||||||
violations={policyViolationModal.violations}
|
|
||||||
policyDetails={{
|
|
||||||
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
|
||||||
maxParticipants: systemPolicy.maxParticipants,
|
|
||||||
allowSpectators: systemPolicy.allowSpectators,
|
|
||||||
maxSpectators: systemPolicy.maxSpectators,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom RequestDetail Component (Exported)
|
|
||||||
*/
|
|
||||||
export function CustomRequestDetail(props: RequestDetailProps) {
|
|
||||||
return (
|
|
||||||
<RequestDetailErrorBoundary>
|
|
||||||
<CustomRequestDetailInner {...props} />
|
|
||||||
</RequestDetailErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Filter, Search, SortAsc, SortDesc, X, CheckCircle, XCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
interface DealerClosedRequestsFiltersProps {
|
|
||||||
searchTerm: string;
|
|
||||||
statusFilter?: string;
|
|
||||||
priorityFilter?: string;
|
|
||||||
templateTypeFilter?: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
|
||||||
sortOrder: 'asc' | 'desc';
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onStatusChange?: (value: string) => void;
|
|
||||||
onPriorityChange?: (value: string) => void;
|
|
||||||
onTemplateTypeChange?: (value: string) => void;
|
|
||||||
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
|
|
||||||
onSortOrderChange: () => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
activeFiltersCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dealer Closed Requests Filters Component
|
|
||||||
*
|
|
||||||
* Simplified filters for dealer users viewing closed requests.
|
|
||||||
* Only includes: Search, Status (closure type), and Sort filters.
|
|
||||||
* Removes: Priority and Template Type filters.
|
|
||||||
*/
|
|
||||||
export function DealerClosedRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
statusFilter = 'all',
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
onSearchChange,
|
|
||||||
onStatusChange,
|
|
||||||
onSortByChange,
|
|
||||||
onSortOrderChange,
|
|
||||||
onClearFilters,
|
|
||||||
activeFiltersCount,
|
|
||||||
...rest // Accept but ignore other props for interface compatibility
|
|
||||||
}: DealerClosedRequestsFiltersProps) {
|
|
||||||
void rest; // Explicitly mark as unused
|
|
||||||
return (
|
|
||||||
<Card className="shadow-lg border-0" data-testid="dealer-closed-requests-filters">
|
|
||||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
|
|
||||||
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<span className="text-blue-600 font-medium">
|
|
||||||
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
|
||||||
data-testid="dealer-closed-requests-clear-filters"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="text-xs sm:text-sm">Clear</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
|
||||||
{/* Dealer-specific filters - Search, Status (Closure Type), and Sort */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests, IDs..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
|
|
||||||
data-testid="dealer-closed-requests-search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{onStatusChange && (
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200" data-testid="dealer-closed-requests-status-filter">
|
|
||||||
<SelectValue placeholder="Closure Type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Closures</SelectItem>
|
|
||||||
<SelectItem value="approved">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
||||||
<span>Closed After Approval</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="rejected">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<XCircle className="w-4 h-4 text-red-600" />
|
|
||||||
<span>Closed After Rejection</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200" data-testid="dealer-closed-requests-sort-by">
|
|
||||||
<SelectValue placeholder="Sort by" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="due">Due Date</SelectItem>
|
|
||||||
<SelectItem value="created">Date Created</SelectItem>
|
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onSortOrderChange}
|
|
||||||
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
|
||||||
data-testid="dealer-closed-requests-sort-order"
|
|
||||||
>
|
|
||||||
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Filter, Search, SortAsc, SortDesc, X } from 'lucide-react';
|
|
||||||
|
|
||||||
interface DealerRequestsFiltersProps {
|
|
||||||
searchTerm: string;
|
|
||||||
statusFilter?: string;
|
|
||||||
priorityFilter?: string;
|
|
||||||
templateTypeFilter?: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority' | 'sla';
|
|
||||||
sortOrder: 'asc' | 'desc';
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onStatusFilterChange?: (value: string) => void;
|
|
||||||
onPriorityFilterChange?: (value: string) => void;
|
|
||||||
onTemplateTypeFilterChange?: (value: string) => void;
|
|
||||||
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
|
|
||||||
onSortOrderChange: (value: 'asc' | 'desc') => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
activeFiltersCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dealer Requests Filters Component
|
|
||||||
*
|
|
||||||
* Simplified filters for dealer users.
|
|
||||||
* Only includes: Search and Sort filters (no status, priority, or template type).
|
|
||||||
*/
|
|
||||||
export function DealerRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
onSearchChange,
|
|
||||||
onSortByChange,
|
|
||||||
onSortOrderChange,
|
|
||||||
onClearFilters,
|
|
||||||
activeFiltersCount,
|
|
||||||
...rest // Accept but ignore other props for interface compatibility
|
|
||||||
}: DealerRequestsFiltersProps) {
|
|
||||||
void rest; // Explicitly mark as unused
|
|
||||||
return (
|
|
||||||
<Card className="shadow-lg border-0">
|
|
||||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
|
|
||||||
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<span className="text-blue-600 font-medium">
|
|
||||||
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="text-xs sm:text-sm">Clear</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
|
||||||
{/* Dealer-specific filters - Only Search and Sort */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests, IDs..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="Sort by" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="due">Due Date</SelectItem>
|
|
||||||
<SelectItem value="created">Date Created</SelectItem>
|
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
|
||||||
<SelectItem value="sla">SLA Progress</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
|
|
||||||
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
|
||||||
>
|
|
||||||
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,389 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dealer User All Requests Filters Component
|
|
||||||
*
|
|
||||||
* Simplified filters for dealer users viewing their all requests.
|
|
||||||
* Only includes: Search, Status, Initiator, Approver, and Date Range filters.
|
|
||||||
* Removes: Priority, Template Type, Department, and SLA Compliance filters.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import type { DateRange } from '@/services/dashboard.service';
|
|
||||||
import { CustomDatePicker } from '@/components/ui/date-picker';
|
|
||||||
|
|
||||||
interface DealerUserAllRequestsFiltersProps {
|
|
||||||
// Filters
|
|
||||||
searchTerm: string;
|
|
||||||
statusFilter: string;
|
|
||||||
priorityFilter?: string;
|
|
||||||
templateTypeFilter?: string;
|
|
||||||
departmentFilter?: string;
|
|
||||||
slaComplianceFilter?: string;
|
|
||||||
initiatorFilter: string;
|
|
||||||
approverFilter: string;
|
|
||||||
approverFilterType: 'current' | 'any';
|
|
||||||
dateRange: DateRange;
|
|
||||||
customStartDate?: Date;
|
|
||||||
customEndDate?: Date;
|
|
||||||
showCustomDatePicker: boolean;
|
|
||||||
|
|
||||||
// State for user search
|
|
||||||
initiatorSearch: {
|
|
||||||
selectedUser: { userId: string; email: string; displayName?: string } | null;
|
|
||||||
searchQuery: string;
|
|
||||||
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
|
|
||||||
showResults: boolean;
|
|
||||||
handleSearch: (query: string) => void;
|
|
||||||
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
|
|
||||||
handleClear: () => void;
|
|
||||||
setShowResults: (show: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
approverSearch: {
|
|
||||||
selectedUser: { userId: string; email: string; displayName?: string } | null;
|
|
||||||
searchQuery: string;
|
|
||||||
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
|
|
||||||
showResults: boolean;
|
|
||||||
handleSearch: (query: string) => void;
|
|
||||||
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
|
|
||||||
handleClear: () => void;
|
|
||||||
setShowResults: (show: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onStatusChange: (value: string) => void;
|
|
||||||
onInitiatorChange?: (value: string) => void;
|
|
||||||
onApproverChange?: (value: string) => void;
|
|
||||||
onApproverTypeChange?: (value: 'current' | 'any') => void;
|
|
||||||
onDateRangeChange: (value: DateRange) => void;
|
|
||||||
onCustomStartDateChange?: (date: Date | undefined) => void;
|
|
||||||
onCustomEndDateChange?: (date: Date | undefined) => void;
|
|
||||||
onShowCustomDatePickerChange?: (show: boolean) => void;
|
|
||||||
onApplyCustomDate?: () => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
hasActiveFilters: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DealerUserAllRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
statusFilter,
|
|
||||||
initiatorFilter,
|
|
||||||
approverFilter,
|
|
||||||
approverFilterType,
|
|
||||||
dateRange,
|
|
||||||
customStartDate,
|
|
||||||
customEndDate,
|
|
||||||
showCustomDatePicker,
|
|
||||||
initiatorSearch,
|
|
||||||
approverSearch,
|
|
||||||
onSearchChange,
|
|
||||||
onStatusChange,
|
|
||||||
onInitiatorChange,
|
|
||||||
onApproverChange,
|
|
||||||
onApproverTypeChange,
|
|
||||||
onDateRangeChange,
|
|
||||||
onCustomStartDateChange,
|
|
||||||
onCustomEndDateChange,
|
|
||||||
onShowCustomDatePickerChange,
|
|
||||||
onApplyCustomDate,
|
|
||||||
onClearFilters,
|
|
||||||
hasActiveFilters,
|
|
||||||
...rest // Accept but ignore other props for interface compatibility
|
|
||||||
}: DealerUserAllRequestsFiltersProps) {
|
|
||||||
void rest; // Explicitly mark as unused
|
|
||||||
return (
|
|
||||||
<Card className="border-gray-200 shadow-md" data-testid="dealer-user-all-requests-filters">
|
|
||||||
<CardContent className="p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="w-5 h-5 text-muted-foreground" />
|
|
||||||
<h3 className="font-semibold text-gray-900">Filters</h3>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Primary Filters - Only Search and Status for dealers */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-10 h-10"
|
|
||||||
data-testid="dealer-search-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="dealer-status-filter">
|
|
||||||
<SelectValue placeholder="All Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="paused">Paused</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Filters - Initiator and Approver */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
|
||||||
{/* Initiator Filter */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
|
|
||||||
<div className="relative">
|
|
||||||
{initiatorSearch.selectedUser ? (
|
|
||||||
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
|
|
||||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
|
||||||
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="Search initiator..."
|
|
||||||
value={initiatorSearch.searchQuery}
|
|
||||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (initiatorSearch.searchResults.length > 0) {
|
|
||||||
initiatorSearch.setShowResults(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
|
|
||||||
className="h-10"
|
|
||||||
data-testid="dealer-initiator-search-input"
|
|
||||||
/>
|
|
||||||
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{initiatorSearch.searchResults.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => initiatorSearch.handleSelect(user)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{user.displayName || user.email}
|
|
||||||
</span>
|
|
||||||
{user.displayName && (
|
|
||||||
<span className="text-xs text-gray-500">{user.email}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Approver Filter */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Label className="text-sm font-medium text-gray-700">Approver</Label>
|
|
||||||
{approverFilter !== 'all' && onApproverTypeChange && (
|
|
||||||
<Select
|
|
||||||
value={approverFilterType}
|
|
||||||
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-32 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="current">Current Only</SelectItem>
|
|
||||||
<SelectItem value="any">Any Approver</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
{approverSearch.selectedUser ? (
|
|
||||||
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
|
|
||||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
|
||||||
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="Search approver..."
|
|
||||||
value={approverSearch.searchQuery}
|
|
||||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (approverSearch.searchResults.length > 0) {
|
|
||||||
approverSearch.setShowResults(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
|
|
||||||
className="h-10"
|
|
||||||
data-testid="dealer-approver-search-input"
|
|
||||||
/>
|
|
||||||
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{approverSearch.searchResults.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => approverSearch.handleSelect(user)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{user.displayName || user.email}
|
|
||||||
</span>
|
|
||||||
{user.displayName && (
|
|
||||||
<span className="text-xs text-gray-500">{user.email}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Range Filter */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
|
|
||||||
<SelectTrigger className="w-[160px] h-10">
|
|
||||||
<SelectValue placeholder="Date Range" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Time</SelectItem>
|
|
||||||
<SelectItem value="today">Today</SelectItem>
|
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
|
||||||
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
|
||||||
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
|
||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{dateRange === 'custom' && (
|
|
||||||
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
{customStartDate && customEndDate
|
|
||||||
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
|
|
||||||
: 'Select dates'}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-4" align="start">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="start-date">Start Date</Label>
|
|
||||||
<CustomDatePicker
|
|
||||||
value={customStartDate || null}
|
|
||||||
onChange={(dateStr: string | null) => {
|
|
||||||
const date = dateStr ? new Date(dateStr) : undefined;
|
|
||||||
if (date) {
|
|
||||||
onCustomStartDateChange?.(date);
|
|
||||||
if (customEndDate && date > customEndDate) {
|
|
||||||
onCustomEndDateChange?.(date);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onCustomStartDateChange?.(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maxDate={new Date()}
|
|
||||||
placeholderText="dd/mm/yyyy"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="end-date">End Date</Label>
|
|
||||||
<CustomDatePicker
|
|
||||||
value={customEndDate || null}
|
|
||||||
onChange={(dateStr: string | null) => {
|
|
||||||
const date = dateStr ? new Date(dateStr) : undefined;
|
|
||||||
if (date) {
|
|
||||||
onCustomEndDateChange?.(date);
|
|
||||||
if (customStartDate && date < customStartDate) {
|
|
||||||
onCustomStartDateChange?.(date);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onCustomEndDateChange?.(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
minDate={customStartDate || undefined}
|
|
||||||
maxDate={new Date()}
|
|
||||||
placeholderText="dd/mm/yyyy"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-2 border-t">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={onApplyCustomDate}
|
|
||||||
disabled={!customStartDate || !customEndDate}
|
|
||||||
className="flex-1 bg-re-green hover:bg-re-green/90"
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
onShowCustomDatePickerChange?.(false);
|
|
||||||
onCustomStartDateChange?.(undefined);
|
|
||||||
onCustomEndDateChange?.(undefined);
|
|
||||||
onDateRangeChange('month');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,503 +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;
|
|
||||||
|
|
||||||
// Get estimated budget from proposal details
|
|
||||||
const proposalDetails = apiRequest?.proposalDetails || {};
|
|
||||||
const estimatedBudget = Number(proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
|
|
||||||
|
|
||||||
const [ioNumber, setIoNumber] = useState(existingIONumber);
|
|
||||||
const [fetchingAmount, setFetchingAmount] = useState(false);
|
|
||||||
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
|
||||||
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
|
||||||
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
|
||||||
const [blockingBudget, setBlockingBudget] = useState(false);
|
|
||||||
|
|
||||||
// Load existing IO block details from apiRequest
|
|
||||||
useEffect(() => {
|
|
||||||
if (internalOrder && existingIONumber) {
|
|
||||||
// IMPORTANT: ioAvailableBalance is already the available balance BEFORE blocking
|
|
||||||
// We should NOT add blockedAmount to it - that would cause double deduction
|
|
||||||
// Backend stores: availableBalance (before block), blockedAmount, remainingBalance (after block)
|
|
||||||
const availableBeforeBlock = Number(existingAvailableBalance) || 0;
|
|
||||||
|
|
||||||
// Get blocked by user name from organizer association (who blocked the amount)
|
|
||||||
// When amount is blocked, organizedBy stores the user who blocked it
|
|
||||||
const blockedByName = organizer?.displayName ||
|
|
||||||
organizer?.display_name ||
|
|
||||||
organizer?.name ||
|
|
||||||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
|
|
||||||
organizer?.email ||
|
|
||||||
'Unknown User';
|
|
||||||
|
|
||||||
// Set IO number from existing data
|
|
||||||
setIoNumber(existingIONumber);
|
|
||||||
|
|
||||||
// Only set blocked details if amount is blocked
|
|
||||||
if (existingBlockedAmount > 0) {
|
|
||||||
const blockedAmt = Number(existingBlockedAmount) || 0;
|
|
||||||
const backendRemaining = Number(existingRemainingBalance) || 0;
|
|
||||||
|
|
||||||
// Calculate expected remaining balance for validation/debugging
|
|
||||||
// Formula: remaining = availableBeforeBlock - blockedAmount
|
|
||||||
const expectedRemaining = availableBeforeBlock - blockedAmt;
|
|
||||||
|
|
||||||
// Loading existing IO block
|
|
||||||
|
|
||||||
// Warn if remaining balance calculation seems incorrect (for backend debugging)
|
|
||||||
if (Math.abs(backendRemaining - expectedRemaining) > 0.01) {
|
|
||||||
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
|
|
||||||
availableBalance: availableBeforeBlock,
|
|
||||||
blockedAmount: blockedAmt,
|
|
||||||
expectedRemaining,
|
|
||||||
backendRemaining,
|
|
||||||
difference: backendRemaining - expectedRemaining,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setBlockedDetails({
|
|
||||||
ioNumber: existingIONumber,
|
|
||||||
blockedAmount: blockedAmt,
|
|
||||||
availableBalance: availableBeforeBlock, // Available amount before block
|
|
||||||
remainingBalance: backendRemaining, // Use backend calculated value
|
|
||||||
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
|
|
||||||
blockedBy: blockedByName,
|
|
||||||
sapDocumentNumber: sapDocNumber,
|
|
||||||
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
|
||||||
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set fetched amount if available balance exists
|
|
||||||
if (availableBeforeBlock > 0) {
|
|
||||||
setFetchedAmount(availableBeforeBlock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch available budget from SAP
|
|
||||||
* Validates IO number and gets available balance (returns dummy data for now)
|
|
||||||
* Does not store anything in database - only validates
|
|
||||||
*/
|
|
||||||
const handleFetchAmount = async () => {
|
|
||||||
if (!ioNumber.trim()) {
|
|
||||||
toast.error('Please enter an IO number');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!requestId) {
|
|
||||||
toast.error('Request ID not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFetchingAmount(true);
|
|
||||||
try {
|
|
||||||
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
|
|
||||||
const ioData = await validateIO(requestId, ioNumber.trim());
|
|
||||||
|
|
||||||
if (ioData.isValid && ioData.availableBalance > 0) {
|
|
||||||
setFetchedAmount(ioData.availableBalance);
|
|
||||||
// Pre-fill amount to block with estimated budget (if available), otherwise use available balance
|
|
||||||
if (estimatedBudget > 0) {
|
|
||||||
setAmountToBlock(String(estimatedBudget));
|
|
||||||
} else {
|
|
||||||
setAmountToBlock(String(ioData.availableBalance));
|
|
||||||
}
|
|
||||||
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
|
||||||
} else {
|
|
||||||
toast.error('Invalid IO number or no available balance found');
|
|
||||||
setFetchedAmount(null);
|
|
||||||
setAmountToBlock('');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to fetch IO budget:', error);
|
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to validate IO number or fetch budget from SAP';
|
|
||||||
toast.error(errorMessage);
|
|
||||||
setFetchedAmount(null);
|
|
||||||
} finally {
|
|
||||||
setFetchingAmount(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Block budget in SAP system
|
|
||||||
* This function:
|
|
||||||
* 1. Validates the IO number and amount
|
|
||||||
* 2. Calls SAP to block the budget
|
|
||||||
* 3. Saves IO number, blocked amount, and balance details to database
|
|
||||||
*/
|
|
||||||
const handleBlockBudget = async () => {
|
|
||||||
if (!ioNumber.trim() || fetchedAmount === null) {
|
|
||||||
toast.error('Please fetch IO amount first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!requestId) {
|
|
||||||
toast.error('Request ID not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockAmountRaw = parseFloat(amountToBlock);
|
|
||||||
|
|
||||||
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
|
|
||||||
toast.error('Please enter a valid amount to block');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round to exactly 2 decimal places to avoid floating point precision issues
|
|
||||||
// Use parseFloat with toFixed to ensure exact 2 decimal precision
|
|
||||||
const blockAmount = parseFloat(blockAmountRaw.toFixed(2));
|
|
||||||
|
|
||||||
if (blockAmount > fetchedAmount) {
|
|
||||||
toast.error('Amount to block exceeds available IO budget');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that amount to block must exactly match estimated budget
|
|
||||||
if (estimatedBudget > 0) {
|
|
||||||
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
|
|
||||||
if (Math.abs(blockAmount - roundedEstimatedBudget) > 0.01) {
|
|
||||||
toast.error(`Amount to block must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blocking budget
|
|
||||||
|
|
||||||
setBlockingBudget(true);
|
|
||||||
try {
|
|
||||||
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
|
|
||||||
// This will store in internal_orders and claim_budget_tracking tables
|
|
||||||
// Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only
|
|
||||||
// Ensure all amounts are rounded to 2 decimal places for consistency
|
|
||||||
const roundedFetchedAmount = parseFloat(fetchedAmount.toFixed(2));
|
|
||||||
const calculatedRemaining = parseFloat((roundedFetchedAmount - blockAmount).toFixed(2));
|
|
||||||
const payload = {
|
|
||||||
ioNumber: ioNumber.trim(),
|
|
||||||
ioAvailableBalance: roundedFetchedAmount,
|
|
||||||
ioBlockedAmount: blockAmount,
|
|
||||||
ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sending to backend
|
|
||||||
|
|
||||||
await updateIODetails(requestId, payload);
|
|
||||||
|
|
||||||
// Fetch updated claim details to get the blocked IO data
|
|
||||||
const claimData = await getClaimDetails(requestId);
|
|
||||||
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
|
||||||
|
|
||||||
if (updatedInternalOrder) {
|
|
||||||
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
|
|
||||||
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
|
|
||||||
|
|
||||||
// Calculate expected remaining balance for validation/debugging
|
|
||||||
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
|
|
||||||
|
|
||||||
// Blocking result processed
|
|
||||||
|
|
||||||
// Warn if the saved amount differs from what we sent
|
|
||||||
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
|
|
||||||
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn if remaining balance calculation seems incorrect (for backend debugging)
|
|
||||||
if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) {
|
|
||||||
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
|
|
||||||
availableBalance: fetchedAmount,
|
|
||||||
blockedAmount: savedBlockedAmount,
|
|
||||||
expectedRemaining: expectedRemainingBalance,
|
|
||||||
backendRemaining: savedRemainingBalance,
|
|
||||||
difference: savedRemainingBalance - expectedRemainingBalance,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUser = user as any;
|
|
||||||
// When blocking, always use the current user who is performing the block action
|
|
||||||
// The organizer association may be from initial IO organization, but we want who blocked the amount
|
|
||||||
const blockedByName = currentUser?.displayName ||
|
|
||||||
currentUser?.display_name ||
|
|
||||||
currentUser?.name ||
|
|
||||||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
|
|
||||||
currentUser?.email ||
|
|
||||||
'Current User';
|
|
||||||
|
|
||||||
const blocked: IOBlockedDetails = {
|
|
||||||
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
|
||||||
blockedAmount: savedBlockedAmount,
|
|
||||||
availableBalance: fetchedAmount, // Available amount before block
|
|
||||||
remainingBalance: savedRemainingBalance,
|
|
||||||
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
|
|
||||||
blockedBy: blockedByName,
|
|
||||||
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
|
||||||
status: 'blocked',
|
|
||||||
};
|
|
||||||
|
|
||||||
setBlockedDetails(blocked);
|
|
||||||
setAmountToBlock(''); // Clear the input
|
|
||||||
toast.success('IO budget blocked successfully in SAP');
|
|
||||||
|
|
||||||
// Refresh request details
|
|
||||||
onRefresh?.();
|
|
||||||
} else {
|
|
||||||
toast.error('IO blocked but failed to fetch updated details');
|
|
||||||
onRefresh?.();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to block IO budget:', error);
|
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to block IO budget in SAP';
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setBlockingBudget(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* IO Budget Management Card */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<DollarSign className="w-5 h-5 text-[#2d4a3e]" />
|
|
||||||
IO Budget Management
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Enter IO number to fetch available budget from SAP
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* IO Number Input */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label htmlFor="ioNumber">IO Number *</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="ioNumber"
|
|
||||||
placeholder="Enter IO number (e.g., IO-2024-12345)"
|
|
||||||
value={ioNumber}
|
|
||||||
onChange={(e) => setIoNumber(e.target.value)}
|
|
||||||
disabled={fetchingAmount || !!blockedDetails}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleFetchAmount}
|
|
||||||
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
|
|
||||||
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
{fetchingAmount ? 'Fetching...' : 'Fetch Amount'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Instructions when IO number is entered but not fetched */}
|
|
||||||
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fetched Amount Display */}
|
|
||||||
{fetchedAmount !== null && !blockedDetails && (
|
|
||||||
<>
|
|
||||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-600 uppercase tracking-wide mb-1">Available Amount</p>
|
|
||||||
<p className="text-2xl font-bold text-green-700">
|
|
||||||
₹{fetchedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CircleCheckBig className="w-8 h-8 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 pt-3 border-t border-green-200">
|
|
||||||
<p className="text-xs text-gray-600"><strong>IO Number:</strong> {ioNumber}</p>
|
|
||||||
<p className="text-xs text-gray-600 mt-1"><strong>Fetched from:</strong> SAP System</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount to Block Input */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label htmlFor="blockAmount">Amount to Block *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">₹</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
id="blockAmount"
|
|
||||||
placeholder="Enter amount to block"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={amountToBlock}
|
|
||||||
onChange={(e) => setAmountToBlock(e.target.value)}
|
|
||||||
className="pl-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{estimatedBudget > 0 && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-amber-800">
|
|
||||||
<strong>Required:</strong> Amount must be exactly equal to the estimated budget: <strong>₹{estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Block Button */}
|
|
||||||
<Button
|
|
||||||
onClick={handleBlockBudget}
|
|
||||||
disabled={
|
|
||||||
blockingBudget ||
|
|
||||||
!amountToBlock ||
|
|
||||||
parseFloat(amountToBlock) <= 0 ||
|
|
||||||
parseFloat(amountToBlock) > fetchedAmount ||
|
|
||||||
(estimatedBudget > 0 && Math.abs(parseFloat(amountToBlock) - estimatedBudget) > 0.01)
|
|
||||||
}
|
|
||||||
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
|
||||||
>
|
|
||||||
<Target className="w-4 h-4 mr-2" />
|
|
||||||
{blockingBudget ? 'Blocking in SAP...' : 'Block IO in SAP'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* IO Blocked Details Card */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<CircleCheckBig className="w-5 h-5 text-green-600" />
|
|
||||||
IO Blocked Details
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Details of IO blocked in SAP system
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{blockedDetails ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Success Banner */}
|
|
||||||
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
|
|
||||||
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Blocked Details */}
|
|
||||||
<div className="border rounded-lg divide-y">
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
|
|
||||||
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
|
|
||||||
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-green-50">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
|
|
||||||
<p className="text-xl font-bold text-green-700">
|
|
||||||
₹{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">
|
|
||||||
₹{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-blue-50">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
|
|
||||||
<p className="text-sm font-bold text-blue-700">
|
|
||||||
₹{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked At</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">
|
|
||||||
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: 'numeric',
|
|
||||||
hour12: true
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-gray-50">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
|
|
||||||
<Badge className="bg-green-100 text-green-800 border-green-200">
|
|
||||||
<CircleCheckBig className="w-3 h-3 mr-1" />
|
|
||||||
Blocked
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<DollarSign className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
|
||||||
<p className="text-sm text-gray-500 mb-2">No IO blocked yet</p>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Enter IO number and fetch amount to block budget
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,296 +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;
|
|
||||||
generationAttempts?: number;
|
|
||||||
generationFailed?: boolean;
|
|
||||||
maxAttemptsReached?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClaimManagementOverviewTab({
|
|
||||||
request: _request,
|
|
||||||
apiRequest,
|
|
||||||
currentUserId,
|
|
||||||
isInitiator: _isInitiator,
|
|
||||||
onEditClaimAmount: _onEditClaimAmount,
|
|
||||||
className = '',
|
|
||||||
needsClosure = false,
|
|
||||||
conclusionRemark = '',
|
|
||||||
setConclusionRemark,
|
|
||||||
conclusionLoading = false,
|
|
||||||
conclusionSubmitting = false,
|
|
||||||
aiGenerated = false,
|
|
||||||
handleGenerateConclusion,
|
|
||||||
handleFinalizeConclusion,
|
|
||||||
generationAttempts = 0,
|
|
||||||
generationFailed = false,
|
|
||||||
maxAttemptsReached = false,
|
|
||||||
}: ClaimManagementOverviewTabProps) {
|
|
||||||
// Check if this is a claim management request
|
|
||||||
if (!isClaimManagementRequest(apiRequest)) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<p>This is not a claim management request.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map API data to claim management structure
|
|
||||||
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
|
|
||||||
|
|
||||||
if (!claimRequest) {
|
|
||||||
console.warn('[ClaimManagementOverviewTab] Failed to map claim data:', {
|
|
||||||
apiRequest,
|
|
||||||
hasClaimDetails: !!apiRequest?.claimDetails,
|
|
||||||
hasProposalDetails: !!apiRequest?.proposalDetails,
|
|
||||||
hasCompletionDetails: !!apiRequest?.completionDetails,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<p>Unable to load claim management data.</p>
|
|
||||||
<p className="text-xs mt-2">Please ensure the request has been properly initialized.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapped claim data ready
|
|
||||||
|
|
||||||
// Determine user's role
|
|
||||||
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
|
|
||||||
|
|
||||||
// Get visibility settings based on role
|
|
||||||
const visibility = getRoleBasedVisibility(userRole);
|
|
||||||
|
|
||||||
// User role and visibility determined
|
|
||||||
|
|
||||||
// Extract initiator info from request
|
|
||||||
// The apiRequest has initiator object with displayName, email, department, phone, etc.
|
|
||||||
const initiatorInfo = {
|
|
||||||
name: apiRequest.initiator?.name || apiRequest.initiator?.displayName || apiRequest.initiator?.email || 'Unknown',
|
|
||||||
role: apiRequest.initiator?.role || apiRequest.initiator?.designation || 'Initiator',
|
|
||||||
department: apiRequest.initiator?.department || apiRequest.department || '',
|
|
||||||
email: apiRequest.initiator?.email || 'N/A',
|
|
||||||
phone: apiRequest.initiator?.phone || apiRequest.initiator?.mobile,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Closure setup check completed
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-6 ${className}`}>
|
|
||||||
{/* Activity Information - Always visible */}
|
|
||||||
{/* Dealer-claim module: Business logic for preparing timestamp data */}
|
|
||||||
<ActivityInformationCard
|
|
||||||
activityInfo={claimRequest.activityInfo}
|
|
||||||
createdAt={apiRequest?.createdAt}
|
|
||||||
updatedAt={apiRequest?.updatedAt}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dealer Information - Always visible */}
|
|
||||||
<DealerInformationCard dealerInfo={claimRequest.dealerInfo} />
|
|
||||||
|
|
||||||
{/* Proposal Details - Only shown after dealer submits proposal */}
|
|
||||||
{visibility.showProposalDetails && claimRequest.proposalDetails && (
|
|
||||||
<ProposalDetailsCard proposalDetails={claimRequest.proposalDetails} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Request Initiator */}
|
|
||||||
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
|
|
||||||
|
|
||||||
{/* Closed Request Conclusion Remark Display */}
|
|
||||||
{apiRequest?.status === 'closed' && apiRequest?.conclusionRemark && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<CheckCircle className="w-5 h-5 text-gray-600" />
|
|
||||||
Conclusion Remark
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1 text-xs sm:text-sm">Final summary of this closed request</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
|
||||||
<FormattedDescription
|
|
||||||
content={apiRequest.conclusionRemark || ''}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{apiRequest.closureDate && (
|
|
||||||
<div className="mt-3 flex items-center justify-between text-xs text-gray-500 border-t border-gray-200 pt-3">
|
|
||||||
<span>Request closed on {formatDateTime(apiRequest.closureDate)}</span>
|
|
||||||
<span>By {initiatorInfo.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Conclusion Remark Section - Closure Setup */}
|
|
||||||
{needsClosure && (
|
|
||||||
<Card data-testid="conclusion-remark-card">
|
|
||||||
<CardHeader className={`bg-gradient-to-r border-b ${
|
|
||||||
(apiRequest?.status || '').toLowerCase() === 'rejected'
|
|
||||||
? 'from-red-50 to-rose-50 border-red-200'
|
|
||||||
: 'from-green-50 to-emerald-50 border-green-200'
|
|
||||||
}`}>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
|
|
||||||
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
|
|
||||||
}`}>
|
|
||||||
<CheckCircle className={`w-5 h-5 ${
|
|
||||||
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
|
|
||||||
}`} />
|
|
||||||
Conclusion Remark - Final Step
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1 text-xs sm:text-sm">
|
|
||||||
{(apiRequest?.status || '').toLowerCase() === 'rejected'
|
|
||||||
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
|
|
||||||
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{handleGenerateConclusion && (
|
|
||||||
<div className="flex flex-col items-end gap-1.5">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleGenerateConclusion}
|
|
||||||
disabled={conclusionLoading || maxAttemptsReached}
|
|
||||||
className="gap-2 shrink-0 h-9"
|
|
||||||
data-testid="generate-ai-conclusion-button"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
|
|
||||||
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
|
|
||||||
</Button>
|
|
||||||
{aiGenerated && !maxAttemptsReached && !generationFailed && (
|
|
||||||
<span className="text-[10px] text-gray-500 font-medium px-1">
|
|
||||||
{2 - generationAttempts} attempts remaining
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
{conclusionLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8" data-testid="conclusion-loading">
|
|
||||||
<div className="text-center">
|
|
||||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-gray-600">Preparing conclusion remark...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700">Conclusion Remark</label>
|
|
||||||
{aiGenerated && (
|
|
||||||
<span className="text-xs text-blue-600" data-testid="ai-generated-label">
|
|
||||||
✓ System-generated suggestion (editable)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{setConclusionRemark && (
|
|
||||||
<RichTextEditor
|
|
||||||
value={conclusionRemark}
|
|
||||||
onChange={(html) => setConclusionRemark(html)}
|
|
||||||
placeholder="Enter a professional conclusion remark summarizing the request outcome, key decisions, and approvals..."
|
|
||||||
className="text-sm"
|
|
||||||
minHeight="160px"
|
|
||||||
data-testid="conclusion-remark-textarea"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
|
||||||
💡 Tip: You can paste formatted content (lists, tables) and the formatting will be preserved.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-2">
|
|
||||||
<p className="text-xs text-gray-500">This will be the final summary for this request</p>
|
|
||||||
<p className="text-xs text-gray-500" data-testid="character-count">
|
|
||||||
{conclusionRemark ? conclusionRemark.replace(/<[^>]*>/g, '').length : 0} / 2000 characters
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
||||||
<p className="text-xs sm:text-sm font-semibold text-blue-900 mb-1.5">Finalizing this request will:</p>
|
|
||||||
<ul className="text-xs sm:text-sm text-blue-800 space-y-0.5 pl-4">
|
|
||||||
<li className="list-disc">Change request status to "CLOSED"</li>
|
|
||||||
<li className="list-disc">Notify all participants of closure</li>
|
|
||||||
<li className="list-disc">Move request to Closed Requests</li>
|
|
||||||
<li className="list-disc">Save conclusion remark permanently</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{handleFinalizeConclusion && (
|
|
||||||
<div className="flex gap-3 justify-end pt-3 border-t">
|
|
||||||
<Button
|
|
||||||
onClick={handleFinalizeConclusion}
|
|
||||||
disabled={conclusionSubmitting || !conclusionRemark.trim()}
|
|
||||||
className="bg-green-600 hover:bg-green-700 text-white"
|
|
||||||
data-testid="finalize-close-button"
|
|
||||||
>
|
|
||||||
{conclusionSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Finalizing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
|
||||||
Finalize & Close Request
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export as DealerClaimOverviewTab for consistency
|
|
||||||
export { ClaimManagementOverviewTab as DealerClaimOverviewTab };
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,206 +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';
|
|
||||||
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
|
||||||
|
|
||||||
interface ActivityInformationCardProps {
|
|
||||||
activityInfo: ClaimActivityInfo;
|
|
||||||
className?: string;
|
|
||||||
// Plug-and-play: Pass timestamps from module's business logic
|
|
||||||
createdAt?: string | Date;
|
|
||||||
updatedAt?: string | Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivityInformationCard({
|
|
||||||
activityInfo,
|
|
||||||
className,
|
|
||||||
createdAt,
|
|
||||||
updatedAt
|
|
||||||
}: ActivityInformationCardProps) {
|
|
||||||
// Defensive check: Ensure activityInfo exists
|
|
||||||
if (!activityInfo) {
|
|
||||||
console.warn('[ActivityInformationCard] activityInfo is missing');
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardContent className="py-8 text-center text-gray-500">
|
|
||||||
<p>Activity information not available</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCurrency = (amount: string | number) => {
|
|
||||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
|
|
||||||
if (isNaN(numAmount)) return 'N/A';
|
|
||||||
return `₹${numAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString?: string) => {
|
|
||||||
if (!dateString) return 'N/A';
|
|
||||||
try {
|
|
||||||
return format(new Date(dateString), 'MMM d, yyyy');
|
|
||||||
} catch {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Calendar className="w-5 h-5 text-blue-600" />
|
|
||||||
Activity Information
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{/* Activity Name */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Activity Name
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1">
|
|
||||||
{activityInfo.activityName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Activity Type */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Activity Type
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1">
|
|
||||||
{activityInfo.activityType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Location
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4 text-gray-400" />
|
|
||||||
{activityInfo.location}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Requested Date */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Requested Date
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1">
|
|
||||||
{formatDate(activityInfo.requestedDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Estimated Budget */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Estimated Budget
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
|
||||||
<DollarSign className="w-4 h-4 text-green-600" />
|
|
||||||
{activityInfo.estimatedBudget
|
|
||||||
? formatCurrency(activityInfo.estimatedBudget)
|
|
||||||
: 'TBD'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Closed Expenses - Show if value exists (including 0) */}
|
|
||||||
{activityInfo.closedExpenses !== undefined && activityInfo.closedExpenses !== null && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Closed Expenses
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
|
||||||
<Receipt className="w-4 h-4 text-blue-600" />
|
|
||||||
{formatCurrency(activityInfo.closedExpenses)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Period */}
|
|
||||||
{activityInfo.period && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Period
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1">
|
|
||||||
{formatDate(activityInfo.period.startDate)} - {formatDate(activityInfo.period.endDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Closed Expenses Breakdown */}
|
|
||||||
{activityInfo.closedExpensesBreakdown && activityInfo.closedExpensesBreakdown.length > 0 && (
|
|
||||||
<div className="pt-4 border-t">
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
|
|
||||||
Closed Expenses Breakdown
|
|
||||||
</label>
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
|
|
||||||
{activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => (
|
|
||||||
<div key={index} className="flex justify-between items-center text-sm">
|
|
||||||
<span className="text-gray-700">{item.description}</span>
|
|
||||||
<span className="font-medium text-gray-900">
|
|
||||||
{formatCurrency(item.amount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="pt-2 border-t border-blue-300 flex justify-between items-center">
|
|
||||||
<span className="font-semibold text-gray-900">Total</span>
|
|
||||||
<span className="font-bold text-blue-600">
|
|
||||||
{formatCurrency(
|
|
||||||
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{activityInfo.description && (
|
|
||||||
<div className="pt-4 border-t">
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200">
|
|
||||||
<FormattedDescription
|
|
||||||
content={activityInfo.description || ''}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamps - Similar to Request Details Card */}
|
|
||||||
{(createdAt || updatedAt) && (
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-300">
|
|
||||||
{createdAt && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Created</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1">{formatDateTime(createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{updatedAt && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Last Updated</label>
|
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1">{formatDateTime(updatedAt)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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,237 +0,0 @@
|
|||||||
/**
|
|
||||||
* AdditionalApproverReviewModal Component
|
|
||||||
* Modal for Additional Approvers to review request and approve/reject
|
|
||||||
* Similar to InitiatorProposalApprovalModal but simpler - shows request details
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
FileText,
|
|
||||||
MessageSquare,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
|
||||||
|
|
||||||
interface AdditionalApproverReviewModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApprove: (comments: string) => Promise<void>;
|
|
||||||
onReject: (comments: string) => Promise<void>;
|
|
||||||
requestTitle?: string;
|
|
||||||
requestDescription?: string;
|
|
||||||
requestId?: string;
|
|
||||||
levelName?: string;
|
|
||||||
approverName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdditionalApproverReviewModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onApprove,
|
|
||||||
onReject,
|
|
||||||
requestTitle = 'Request',
|
|
||||||
requestDescription = '',
|
|
||||||
requestId,
|
|
||||||
levelName = 'Approval Level',
|
|
||||||
approverName = 'Approver',
|
|
||||||
}: AdditionalApproverReviewModalProps) {
|
|
||||||
const [comments, setComments] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
if (!comments.trim()) {
|
|
||||||
toast.error('Please provide approval comments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
setActionType('approve');
|
|
||||||
await onApprove(comments);
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to approve request:', error);
|
|
||||||
toast.error('Failed to approve request. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
setActionType(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReject = async () => {
|
|
||||||
if (!comments.trim()) {
|
|
||||||
toast.error('Please provide rejection reason');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
setActionType('reject');
|
|
||||||
await onReject(comments);
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to reject request:', error);
|
|
||||||
toast.error('Failed to reject request. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
setActionType(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setComments('');
|
|
||||||
setActionType(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!submitting) {
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col max-w-3xl">
|
|
||||||
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl">
|
|
||||||
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
||||||
Review Request
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs lg:text-sm">
|
|
||||||
{levelName}: Review request details and make a decision
|
|
||||||
</DialogDescription>
|
|
||||||
<div className="space-y-1 mt-2 text-xs text-gray-600">
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
|
||||||
<div>
|
|
||||||
<strong>Request ID:</strong> {requestId || 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Approver:</strong> {approverName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Request Title */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4 text-blue-600" />
|
|
||||||
Request Title
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
|
|
||||||
<p className="text-sm lg:text-base font-medium text-gray-900">{requestTitle}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Request Description */}
|
|
||||||
{requestDescription && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<MessageSquare className="w-4 h-4 text-blue-600" />
|
|
||||||
Request Description
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[200px] overflow-y-auto">
|
|
||||||
<FormattedDescription
|
|
||||||
content={requestDescription}
|
|
||||||
className="text-xs lg:text-sm text-gray-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Decision Section */}
|
|
||||||
<div className="space-y-2 border-t pt-3 lg:pt-3">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
|
|
||||||
value={comments}
|
|
||||||
onChange={(e) => setComments(e.target.value)}
|
|
||||||
className="min-h-[80px] lg:min-h-[90px] text-xs lg:text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">{comments.length} characters</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Warning for missing comments */}
|
|
||||||
{!comments.trim() && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2">
|
|
||||||
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-xs text-amber-800">
|
|
||||||
Please provide comments before making a decision. Comments are required and will be visible to all participants.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-3 lg:pt-4 flex-shrink-0 border-t bg-gray-50">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
className="border-2"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleReject}
|
|
||||||
disabled={!comments.trim() || submitting}
|
|
||||||
variant="destructive"
|
|
||||||
className="bg-red-600 hover:bg-red-700"
|
|
||||||
>
|
|
||||||
{submitting && actionType === 'reject' ? (
|
|
||||||
'Rejecting...'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XCircle className="w-4 h-4 mr-2" />
|
|
||||||
Reject
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleApprove}
|
|
||||||
disabled={!comments.trim() || submitting}
|
|
||||||
className="bg-green-600 hover:bg-green-700 text-white"
|
|
||||||
>
|
|
||||||
{submitting && actionType === 'approve' ? (
|
|
||||||
'Approving...'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
|
||||||
Approve
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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' | 'CONFIRMED';
|
|
||||||
};
|
|
||||||
dealerInfo?: {
|
|
||||||
dealerName?: string;
|
|
||||||
dealerCode?: string;
|
|
||||||
dealerEmail?: string;
|
|
||||||
};
|
|
||||||
activityName?: string;
|
|
||||||
requestNumber?: string;
|
|
||||||
requestId?: string;
|
|
||||||
dueDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreditNoteSAPModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onDownload,
|
|
||||||
onSendToDealer,
|
|
||||||
creditNoteData,
|
|
||||||
dealerInfo,
|
|
||||||
activityName,
|
|
||||||
requestNumber,
|
|
||||||
requestId: _requestId,
|
|
||||||
dueDate,
|
|
||||||
}: CreditNoteSAPModalProps) {
|
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
|
|
||||||
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
|
|
||||||
const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
|
|
||||||
const creditNoteDate = creditNoteData?.creditNoteDate
|
|
||||||
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
|
|
||||||
: '';
|
|
||||||
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
|
|
||||||
const status = creditNoteData?.status || 'PENDING';
|
|
||||||
|
|
||||||
const dealerName = dealerInfo?.dealerName || 'Jaipur Royal Enfield';
|
|
||||||
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
|
|
||||||
const activity = activityName || 'Activity';
|
|
||||||
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
|
|
||||||
const dueDateDisplay = dueDate
|
|
||||||
? formatDateTime(dueDate, { includeTime: false, format: 'short' })
|
|
||||||
: 'Jan 4, 2026';
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
if (onDownload) {
|
|
||||||
try {
|
|
||||||
setDownloading(true);
|
|
||||||
await onDownload();
|
|
||||||
toast.success('Credit note downloaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download credit note:', error);
|
|
||||||
toast.error('Failed to download credit note. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setDownloading(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default behavior: show info message
|
|
||||||
toast.info('Credit note will be automatically saved to Documents tab');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendToDealer = async () => {
|
|
||||||
if (onSendToDealer) {
|
|
||||||
try {
|
|
||||||
setSending(true);
|
|
||||||
await onSendToDealer();
|
|
||||||
toast.success('Credit note sent to dealer successfully');
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send credit note to dealer:', error);
|
|
||||||
toast.error('Failed to send credit note. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default behavior: show info message
|
|
||||||
toast.info('Email notification will be sent to dealer with credit note attachment');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="sm:max-w-lg lg:max-w-[1000px] max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
|
|
||||||
<Receipt className="w-6 h-6 text-[--re-green]" />
|
|
||||||
Credit Note from SAP
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-base">
|
|
||||||
Review and send credit note to dealer
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-5 py-4">
|
|
||||||
{hasCreditNote ? (
|
|
||||||
<>
|
|
||||||
{/* Credit Note Document Card */}
|
|
||||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 rounded-lg p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-green-900 text-xl mb-1">Royal Enfield</h3>
|
|
||||||
<p className="text-sm text-green-700">Credit Note Document</p>
|
|
||||||
</div>
|
|
||||||
<Badge className="bg-green-600 text-white px-4 py-2 text-base">
|
|
||||||
<CircleCheckBig className="w-4 h-4 mr-2" />
|
|
||||||
{status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-green-100">
|
|
||||||
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1">
|
|
||||||
<Hash className="w-3 h-3" />
|
|
||||||
Credit Note Number
|
|
||||||
</Label>
|
|
||||||
<p className="font-bold text-gray-900 mt-1 text-lg">{creditNoteNumber}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-green-100">
|
|
||||||
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1">
|
|
||||||
<Calendar className="w-3 h-3" />
|
|
||||||
Issue Date
|
|
||||||
</Label>
|
|
||||||
<p className="font-semibold text-gray-900 mt-1">{creditNoteDate}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Credit Note Amount */}
|
|
||||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-5">
|
|
||||||
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1 mb-3">
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
Credit Note Amount
|
|
||||||
</Label>
|
|
||||||
<p className="text-4xl font-bold text-blue-700">{formatCurrency(creditNoteAmount)}</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
/* No Credit Note Available */
|
|
||||||
<div className="bg-gray-50 border-2 border-gray-300 rounded-lg p-8 text-center">
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-4">
|
|
||||||
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center">
|
|
||||||
<Receipt className="w-8 h-8 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">No Credit Note Available</h3>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Credit note has not been generated yet. Please wait for the credit note to be generated from DMS.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dealer Information */}
|
|
||||||
<div className="bg-purple-50 border-2 border-purple-200 rounded-lg p-5">
|
|
||||||
<h3 className="font-semibold text-purple-900 mb-4 flex items-center gap-2">
|
|
||||||
<Building className="w-5 h-5" />
|
|
||||||
Dealer Information
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-purple-100">
|
|
||||||
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
|
|
||||||
Dealer Name
|
|
||||||
</Label>
|
|
||||||
<p className="font-semibold text-gray-900 mt-1">{dealerName}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-purple-100">
|
|
||||||
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
|
|
||||||
Dealer Code
|
|
||||||
</Label>
|
|
||||||
<p className="font-semibold text-gray-900 mt-1">{dealerCode}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-purple-100">
|
|
||||||
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
|
|
||||||
Activity
|
|
||||||
</Label>
|
|
||||||
<p className="font-semibold text-gray-900 mt-1">{activity}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reference Details */}
|
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4" />
|
|
||||||
Reference Details
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600">
|
|
||||||
Request ID
|
|
||||||
</Label>
|
|
||||||
<p className="font-medium text-gray-900 mt-1">{requestIdDisplay}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600">
|
|
||||||
Due Date
|
|
||||||
</Label>
|
|
||||||
<p className="font-medium text-gray-900 mt-1">{dueDateDisplay}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Available Actions Info */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
|
||||||
<FileText className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="text-sm text-blue-800">
|
|
||||||
<p className="font-semibold mb-2">Available Actions</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
|
||||||
<li>
|
|
||||||
<strong>Download:</strong> Credit note will be automatically saved to Documents tab
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Send to Dealer:</strong> Email notification will be sent to dealer with credit note attachment
|
|
||||||
</li>
|
|
||||||
<li>All actions will be recorded in activity trail for audit purposes</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row flex items-center justify-between sm:justify-between">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={downloading || sending}
|
|
||||||
className="border-2"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{hasCreditNote && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={downloading || sending}
|
|
||||||
className="border-blue-600 text-blue-600 hover:bg-blue-50"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
{downloading ? 'Downloading...' : 'Download'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSendToDealer}
|
|
||||||
disabled={downloading || sending}
|
|
||||||
className="bg-green-600 hover:bg-green-700 text-white shadow-md"
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4 mr-2" />
|
|
||||||
{sending ? 'Sending...' : 'Send to Dealer'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
.dms-push-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.dms-push-modal {
|
|
||||||
width: 95vw !important;
|
|
||||||
max-width: 95vw !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet and small desktop */
|
|
||||||
@media (min-width: 641px) and (max-width: 1023px) {
|
|
||||||
.dms-push-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Large screens - fixed max-width for better readability */
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.dms-push-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 1000px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extra large screens */
|
|
||||||
@media (min-width: 1536px) {
|
|
||||||
.dms-push-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 1000px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,872 +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, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
Receipt,
|
|
||||||
DollarSign,
|
|
||||||
TriangleAlert,
|
|
||||||
Activity,
|
|
||||||
CheckCircle2,
|
|
||||||
Download,
|
|
||||||
Eye,
|
|
||||||
Loader2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
|
||||||
import '@/components/common/FilePreview/FilePreview.css';
|
|
||||||
import './DMSPushModal.css';
|
|
||||||
|
|
||||||
interface ExpenseItem {
|
|
||||||
description: string;
|
|
||||||
amount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompletionDetails {
|
|
||||||
activityCompletionDate?: string;
|
|
||||||
numberOfParticipants?: number;
|
|
||||||
closedExpenses?: ExpenseItem[];
|
|
||||||
totalClosedExpenses?: number;
|
|
||||||
completionDescription?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IODetails {
|
|
||||||
ioNumber?: string;
|
|
||||||
blockedAmount?: number;
|
|
||||||
availableBalance?: number;
|
|
||||||
remainingBalance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompletionDocuments {
|
|
||||||
completionDocuments?: Array<{
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
id?: string;
|
|
||||||
}>;
|
|
||||||
activityPhotos?: Array<{
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
id?: string;
|
|
||||||
}>;
|
|
||||||
invoicesReceipts?: Array<{
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
id?: string;
|
|
||||||
}>;
|
|
||||||
attendanceSheet?: {
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DMSPushModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onPush: (comments: string) => Promise<void>;
|
|
||||||
completionDetails?: CompletionDetails | null;
|
|
||||||
ioDetails?: IODetails | null;
|
|
||||||
completionDocuments?: CompletionDocuments | null;
|
|
||||||
requestTitle?: string;
|
|
||||||
requestNumber?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DMSPushModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onPush,
|
|
||||||
completionDetails,
|
|
||||||
ioDetails,
|
|
||||||
completionDocuments,
|
|
||||||
requestTitle,
|
|
||||||
requestNumber,
|
|
||||||
}: DMSPushModalProps) {
|
|
||||||
const [comments, setComments] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [previewDocument, setPreviewDocument] = useState<{
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
type?: string;
|
|
||||||
size?: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
|
||||||
|
|
||||||
const commentsChars = comments.length;
|
|
||||||
const maxCommentsChars = 500;
|
|
||||||
|
|
||||||
// Calculate total closed expenses
|
|
||||||
const totalClosedExpenses = useMemo(() => {
|
|
||||||
if (completionDetails?.totalClosedExpenses) {
|
|
||||||
return completionDetails.totalClosedExpenses;
|
|
||||||
}
|
|
||||||
if (completionDetails?.closedExpenses && Array.isArray(completionDetails.closedExpenses)) {
|
|
||||||
return completionDetails.closedExpenses.reduce((sum, item) => {
|
|
||||||
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
|
||||||
return sum + (Number(amount) || 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}, [completionDetails]);
|
|
||||||
|
|
||||||
// Format date
|
|
||||||
const formatDate = (dateString?: string) => {
|
|
||||||
if (!dateString) return '—';
|
|
||||||
try {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-IN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format currency
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if document can be previewed
|
|
||||||
const canPreviewDocument = (doc: { name: string; url?: string; id?: string }): boolean => {
|
|
||||||
if (!doc.name) return false;
|
|
||||||
const name = doc.name.toLowerCase();
|
|
||||||
return name.endsWith('.pdf') ||
|
|
||||||
name.endsWith('.jpg') ||
|
|
||||||
name.endsWith('.jpeg') ||
|
|
||||||
name.endsWith('.png') ||
|
|
||||||
name.endsWith('.gif') ||
|
|
||||||
name.endsWith('.webp');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle document preview - fetch as blob to avoid CSP issues
|
|
||||||
const handlePreviewDocument = async (doc: { name: string; url?: string; id?: string }) => {
|
|
||||||
if (!doc.id) {
|
|
||||||
toast.error('Document preview not available - document ID missing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreviewLoading(true);
|
|
||||||
try {
|
|
||||||
const previewUrl = getDocumentPreviewUrl(doc.id);
|
|
||||||
|
|
||||||
// Determine file type from name
|
|
||||||
const fileName = doc.name.toLowerCase();
|
|
||||||
const isPDF = fileName.endsWith('.pdf');
|
|
||||||
const isImage = fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i);
|
|
||||||
|
|
||||||
// Fetch document as a blob to create a blob URL (CSP compliant)
|
|
||||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
|
||||||
const token = isProduction ? null : localStorage.getItem('accessToken');
|
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
'Accept': isPDF ? 'application/pdf' : '*/*'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isProduction && token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(previewUrl, {
|
|
||||||
headers,
|
|
||||||
credentials: 'include',
|
|
||||||
mode: 'cors'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
if (blob.size === 0) {
|
|
||||||
throw new Error('File is empty or could not be loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create blob URL (CSP compliant - uses 'blob:' protocol)
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
setPreviewDocument({
|
|
||||||
name: doc.name,
|
|
||||||
url: blobUrl,
|
|
||||||
type: blob.type || (isPDF ? 'application/pdf' : isImage ? 'image' : undefined),
|
|
||||||
size: blob.size,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load document preview:', error);
|
|
||||||
toast.error('Failed to load document preview');
|
|
||||||
} finally {
|
|
||||||
setPreviewLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (previewDocument?.url && previewDocument.url.startsWith('blob:')) {
|
|
||||||
window.URL.revokeObjectURL(previewDocument.url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [previewDocument]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!comments.trim()) {
|
|
||||||
toast.error('Please provide comments before pushing to DMS');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
await onPush(comments.trim());
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to push to DMS:', error);
|
|
||||||
toast.error('Failed to push to DMS. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setComments('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!submitting) {
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="dms-push-modal overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
|
||||||
<div className="p-1.5 sm:p-2 rounded-lg bg-indigo-100">
|
|
||||||
<Activity className="w-4 h-4 sm:w-5 sm:h-5 sm:w-6 sm:h-6 text-indigo-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<DialogTitle className="font-semibold text-lg sm:text-xl">
|
|
||||||
Push to DMS - Verification
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs sm:text-sm mt-1">
|
|
||||||
Review completion details and expenses before pushing to DMS for e-invoice generation
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Request Info Card - Grid layout */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg border">
|
|
||||||
<div className="flex items-center justify-between sm:flex-col sm:items-start sm:gap-1">
|
|
||||||
<span className="font-medium text-xs sm:text-sm text-gray-600">Workflow Step:</span>
|
|
||||||
<Badge variant="outline" className="font-mono text-xs">Requestor Claim Approval</Badge>
|
|
||||||
</div>
|
|
||||||
{requestNumber && (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium text-xs sm:text-sm text-gray-600">Request Number:</span>
|
|
||||||
<p className="text-gray-700 font-mono text-xs sm:text-sm">{requestNumber}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium text-xs sm:text-sm text-gray-600">Title:</span>
|
|
||||||
<p className="text-gray-700 text-xs sm:text-sm line-clamp-2">{requestTitle || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 sm:flex-col sm:items-start sm:gap-1">
|
|
||||||
<span className="font-medium text-xs sm:text-sm text-gray-600">Action:</span>
|
|
||||||
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs">
|
|
||||||
<Activity className="w-3 h-3 mr-1" />
|
|
||||||
PUSH TO DMS
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3">
|
|
||||||
<div className="space-y-3 sm:space-y-4 max-w-7xl mx-auto">
|
|
||||||
{/* Grid layout for all three cards on larger screens */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
|
||||||
{/* Completion Details Card */}
|
|
||||||
{completionDetails && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
||||||
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
|
||||||
Completion Details
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
Review activity completion information
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 sm:space-y-3">
|
|
||||||
{completionDetails.activityCompletionDate && (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
||||||
<span className="text-xs sm:text-sm text-gray-600">Activity Completion Date:</span>
|
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
||||||
{formatDate(completionDetails.activityCompletionDate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{completionDetails.numberOfParticipants !== undefined && (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
||||||
<span className="text-xs sm:text-sm text-gray-600">Number of Participants:</span>
|
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
||||||
{completionDetails.numberOfParticipants}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{completionDetails.completionDescription && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<p className="text-xs text-gray-600 mb-1">Completion Description:</p>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-900 line-clamp-3">
|
|
||||||
{completionDetails.completionDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* IO Details Card */}
|
|
||||||
{ioDetails && ioDetails.ioNumber && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
||||||
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
|
||||||
IO Details
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
Internal Order information for budget reference
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 sm:space-y-3">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
||||||
<span className="text-xs sm:text-sm text-gray-600">IO Number:</span>
|
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900 font-mono">
|
|
||||||
{ioDetails.ioNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{ioDetails.blockedAmount !== undefined && ioDetails.blockedAmount > 0 && (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
||||||
<span className="text-xs sm:text-sm text-gray-600">Blocked Amount:</span>
|
|
||||||
<span className="text-xs sm:text-sm font-bold text-green-700">
|
|
||||||
{formatCurrency(ioDetails.blockedAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ioDetails.remainingBalance !== undefined && ioDetails.remainingBalance !== null && (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2">
|
|
||||||
<span className="text-xs sm:text-sm text-gray-600">Remaining Balance:</span>
|
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
||||||
{formatCurrency(ioDetails.remainingBalance)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Expense Breakdown Card */}
|
|
||||||
{completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
||||||
<DollarSign className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
|
||||||
Expense Breakdown
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
Review closed expenses before pushing to DMS
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-1.5 sm:space-y-2 max-h-[200px] overflow-y-auto">
|
|
||||||
{completionDetails.closedExpenses.map((expense, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center justify-between py-1.5 sm:py-2 px-2 sm:px-3 bg-gray-50 rounded border"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0 pr-2">
|
|
||||||
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">
|
|
||||||
{expense.description || `Expense ${index + 1}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 flex-shrink-0">
|
|
||||||
<p className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
||||||
{formatCurrency(typeof expense === 'object' ? (expense.amount || 0) : 0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 sm:py-3 px-2 sm:px-3 bg-blue-50 rounded border-2 border-blue-200 mt-2 sm:mt-3">
|
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900">Total:</span>
|
|
||||||
<span className="text-sm sm:text-base font-bold text-blue-700">
|
|
||||||
{formatCurrency(totalClosedExpenses)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Completion Documents Section */}
|
|
||||||
{completionDocuments && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Completion Documents */}
|
|
||||||
{completionDocuments.completionDocuments && completionDocuments.completionDocuments.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
|
||||||
Completion Documents
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{completionDocuments.completionDocuments.length} file(s)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
||||||
{completionDocuments.completionDocuments.map((doc, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
||||||
<CheckCircle2 className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 flex-shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={doc.name}>
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{doc.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(doc) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(doc)}
|
|
||||||
disabled={previewLoading}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
if (doc.id) {
|
|
||||||
await downloadDocument(doc.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download document:', error);
|
|
||||||
toast.error('Failed to download document');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity Photos */}
|
|
||||||
{completionDocuments.activityPhotos && completionDocuments.activityPhotos.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
||||||
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
|
||||||
Activity Photos
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{completionDocuments.activityPhotos.length} file(s)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
||||||
{completionDocuments.activityPhotos.map((doc, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
||||||
<Activity className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 flex-shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={doc.name}>
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{doc.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(doc) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(doc)}
|
|
||||||
disabled={previewLoading}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Preview photo"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
if (doc.id) {
|
|
||||||
await downloadDocument(doc.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download document:', error);
|
|
||||||
toast.error('Failed to download document');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Download photo"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Invoices / Receipts */}
|
|
||||||
{completionDocuments.invoicesReceipts && completionDocuments.invoicesReceipts.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
||||||
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
|
||||||
Invoices / Receipts
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{completionDocuments.invoicesReceipts.length} file(s)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
||||||
{completionDocuments.invoicesReceipts.map((doc, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
||||||
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 flex-shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={doc.name}>
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{doc.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(doc) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(doc)}
|
|
||||||
disabled={previewLoading}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
if (doc.id) {
|
|
||||||
await downloadDocument(doc.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download document:', error);
|
|
||||||
toast.error('Failed to download document');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Attendance Sheet */}
|
|
||||||
{completionDocuments.attendanceSheet && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
||||||
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-600" />
|
|
||||||
Attendance Sheet
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
||||||
<Activity className="w-4 h-4 lg:w-5 lg:h-5 text-indigo-600 flex-shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={completionDocuments.attendanceSheet.name}>
|
|
||||||
{completionDocuments.attendanceSheet.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{completionDocuments.attendanceSheet.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(completionDocuments.attendanceSheet) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(completionDocuments.attendanceSheet!)}
|
|
||||||
disabled={previewLoading}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
if (completionDocuments.attendanceSheet?.id) {
|
|
||||||
await downloadDocument(completionDocuments.attendanceSheet.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download document:', error);
|
|
||||||
toast.error('Failed to download document');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Verification Warning */}
|
|
||||||
<div className="p-2.5 sm:p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<TriangleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs sm:text-sm font-semibold text-yellow-900">
|
|
||||||
Please verify all details before pushing to DMS
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-yellow-700 mt-1">
|
|
||||||
Once pushed, the system will automatically generate an e-invoice and log it as an activity.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comments & Remarks */}
|
|
||||||
<div className="space-y-1.5 max-w-2xl">
|
|
||||||
<Label htmlFor="comment" className="text-xs sm:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
Comments & Remarks <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="comment"
|
|
||||||
placeholder="Enter your comments about pushing to DMS (e.g., verified expenses, ready for invoice generation)..."
|
|
||||||
value={comments}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
if (value.length <= maxCommentsChars) {
|
|
||||||
setComments(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
rows={4}
|
|
||||||
className="text-sm min-h-[80px] resize-none"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<TriangleAlert className="w-3 h-3" />
|
|
||||||
Required and visible to all
|
|
||||||
</div>
|
|
||||||
<span>{commentsChars}/{maxCommentsChars}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!comments.trim() || submitting}
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
'Pushing to DMS...'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Activity className="w-4 h-4 mr-2" />
|
|
||||||
Push to DMS
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
{/* File Preview Modal - Matching DocumentsTab style */}
|
|
||||||
{previewDocument && (
|
|
||||||
<Dialog
|
|
||||||
open={!!previewDocument}
|
|
||||||
onOpenChange={() => setPreviewDocument(null)}
|
|
||||||
>
|
|
||||||
<DialogContent className="file-preview-dialog p-3 sm:p-6">
|
|
||||||
<div className="file-preview-content">
|
|
||||||
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
|
|
||||||
{previewDocument.name}
|
|
||||||
</DialogTitle>
|
|
||||||
{previewDocument.type && (
|
|
||||||
<p className="text-xs sm:text-sm text-gray-500">
|
|
||||||
{previewDocument.type} {previewDocument.size && `• ${(previewDocument.size / 1024).toFixed(1)} KB`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap mr-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = previewDocument.url;
|
|
||||||
link.download = previewDocument.name;
|
|
||||||
link.click();
|
|
||||||
}}
|
|
||||||
className="gap-2 h-9"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Download</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
|
|
||||||
{previewLoading ? (
|
|
||||||
<div className="flex items-center justify-center h-full min-h-[70vh]">
|
|
||||||
<div className="text-center">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-gray-600">Loading preview...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : previewDocument.name.toLowerCase().endsWith('.pdf') || previewDocument.type?.includes('pdf') ? (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
<iframe
|
|
||||||
src={previewDocument.url}
|
|
||||||
className="w-full h-full rounded-lg border-0"
|
|
||||||
title={previewDocument.name}
|
|
||||||
style={{
|
|
||||||
minHeight: '70vh',
|
|
||||||
height: '100%'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : previewDocument.name.match(/\.(jpg|jpeg|png|gif|webp)$/i) || previewDocument.type?.includes('image') ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<img
|
|
||||||
src={previewDocument.url}
|
|
||||||
alt={previewDocument.name}
|
|
||||||
style={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '100%',
|
|
||||||
objectFit: 'contain'
|
|
||||||
}}
|
|
||||||
className="rounded-lg shadow-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
||||||
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
|
||||||
<Eye className="w-10 h-10 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
|
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
|
||||||
This file type cannot be previewed. Please download to view.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = previewDocument.url;
|
|
||||||
link.download = previewDocument.name;
|
|
||||||
link.click();
|
|
||||||
}}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Download {previewDocument.name}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
.dealer-completion-documents-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.dealer-completion-documents-modal {
|
|
||||||
width: 95vw !important;
|
|
||||||
max-width: 95vw !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet and small desktop */
|
|
||||||
@media (min-width: 641px) and (max-width: 1023px) {
|
|
||||||
.dealer-completion-documents-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Large screens - fixed max-width for better readability */
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.dealer-completion-documents-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 1000px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extra large screens */
|
|
||||||
@media (min-width: 1536px) {
|
|
||||||
.dealer-completion-documents-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 1000px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Date input calendar icon positioning */
|
|
||||||
.dealer-completion-documents-modal input[type="date"] {
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dealer-completion-documents-modal input[type="date"]::-webkit-calendar-picker-indicator {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 1;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dealer-completion-documents-modal input[type="date"]::-webkit-inner-spin-button,
|
|
||||||
.dealer-completion-documents-modal input[type="date"]::-webkit-clear-button {
|
|
||||||
display: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox date input */
|
|
||||||
.dealer-completion-documents-modal input[type="date"]::-moz-calendar-picker-indicator {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,68 +0,0 @@
|
|||||||
.dealer-proposal-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.dealer-proposal-modal {
|
|
||||||
width: 95vw !important;
|
|
||||||
max-width: 95vw !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet and small desktop */
|
|
||||||
@media (min-width: 641px) and (max-width: 1023px) {
|
|
||||||
.dealer-proposal-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Large screens - fixed max-width for better readability */
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.dealer-proposal-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 1000px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extra large screens */
|
|
||||||
@media (min-width: 1536px) {
|
|
||||||
.dealer-proposal-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 1000px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Date input calendar icon positioning */
|
|
||||||
.dealer-proposal-modal input[type="date"] {
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dealer-proposal-modal input[type="date"]::-webkit-calendar-picker-indicator {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 1;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dealer-proposal-modal input[type="date"]::-webkit-inner-spin-button,
|
|
||||||
.dealer-proposal-modal input[type="date"]::-webkit-clear-button {
|
|
||||||
display: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox date input */
|
|
||||||
.dealer-proposal-modal input[type="date"]::-moz-calendar-picker-indicator {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,914 +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, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { CustomDatePicker } from '@/components/ui/date-picker';
|
|
||||||
import { Upload, Plus, Minus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
|
|
||||||
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
|
|
||||||
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
|
||||||
import '@/components/common/FilePreview/FilePreview.css';
|
|
||||||
import './DealerProposalModal.css';
|
|
||||||
|
|
||||||
interface CostItem {
|
|
||||||
id: string;
|
|
||||||
description: string;
|
|
||||||
amount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DealerProposalSubmissionModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: {
|
|
||||||
proposalDocument: File | null;
|
|
||||||
costBreakup: CostItem[];
|
|
||||||
expectedCompletionDate: string;
|
|
||||||
otherDocuments: File[];
|
|
||||||
dealerComments: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
dealerName?: string;
|
|
||||||
activityName?: string;
|
|
||||||
requestId?: string;
|
|
||||||
previousProposalData?: any;
|
|
||||||
documentPolicy: {
|
|
||||||
maxFileSizeMB: number;
|
|
||||||
allowedFileTypes: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DealerProposalSubmissionModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
dealerName = 'Jaipur Royal Enfield',
|
|
||||||
activityName = 'Activity',
|
|
||||||
requestId: _requestId,
|
|
||||||
previousProposalData,
|
|
||||||
documentPolicy,
|
|
||||||
}: DealerProposalSubmissionModalProps) {
|
|
||||||
const [proposalDocument, setProposalDocument] = useState<File | null>(null);
|
|
||||||
const [costItems, setCostItems] = useState<CostItem[]>([
|
|
||||||
{ id: '1', description: '', amount: 0 },
|
|
||||||
]);
|
|
||||||
const [timelineMode, setTimelineMode] = useState<'date' | 'days'>('date');
|
|
||||||
const [expectedCompletionDate, setExpectedCompletionDate] = useState('');
|
|
||||||
const [numberOfDays, setNumberOfDays] = useState('');
|
|
||||||
const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
|
|
||||||
const [dealerComments, setDealerComments] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [previewDoc, setPreviewDoc] = useState<{
|
|
||||||
fileName: string;
|
|
||||||
fileType: string;
|
|
||||||
documentId: string;
|
|
||||||
fileUrl?: string;
|
|
||||||
fileSize?: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [showPreviousProposal, setShowPreviousProposal] = useState(false);
|
|
||||||
|
|
||||||
const proposalDocInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const otherDocsInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Helper function to check if file can be previewed
|
|
||||||
const canPreview = (fileName: string): boolean => {
|
|
||||||
if (!fileName) return false;
|
|
||||||
const name = fileName.toLowerCase();
|
|
||||||
return name.endsWith('.pdf') ||
|
|
||||||
!!name.match(/\.(jpg|jpeg|png|gif|webp)$/i);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canPreviewFile = (file: File): boolean => {
|
|
||||||
return canPreview(file.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup object URLs when component unmounts or file changes
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(previewDoc.fileUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [previewDoc]);
|
|
||||||
|
|
||||||
// Handle manual file preview (for local files)
|
|
||||||
const handlePreviewFile = (file: File) => {
|
|
||||||
if (!canPreviewFile(file)) {
|
|
||||||
toast.error('Preview is only available for images and PDF files');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup previous preview URL if it was a blob
|
|
||||||
if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(previewDoc.fileUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create blob URL for local file
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setPreviewDoc({
|
|
||||||
fileName: file.name,
|
|
||||||
fileType: file.type,
|
|
||||||
documentId: '',
|
|
||||||
fileUrl: url,
|
|
||||||
fileSize: file.size
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle preview for existing Documents (with storageUrl/documentId)
|
|
||||||
const handlePreviewExisting = (doc: any) => {
|
|
||||||
const fileName = doc.originalFileName || doc.fileName || doc.name || 'Document';
|
|
||||||
const documentId = doc.documentId || doc.id || '';
|
|
||||||
const fileType = fileName.toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';
|
|
||||||
|
|
||||||
let fileUrl = '';
|
|
||||||
if (documentId) {
|
|
||||||
fileUrl = getDocumentPreviewUrl(documentId);
|
|
||||||
} else {
|
|
||||||
fileUrl = doc.storageUrl || doc.documentUrl || '';
|
|
||||||
if (fileUrl && !fileUrl.startsWith('http')) {
|
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
||||||
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
|
|
||||||
fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreviewDoc({
|
|
||||||
fileName,
|
|
||||||
fileType,
|
|
||||||
documentId,
|
|
||||||
fileUrl
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle download file (for non-previewable files)
|
|
||||||
const handleDownloadFile = (file: File) => {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = file.name;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate total estimated budget
|
|
||||||
const totalBudget = useMemo(() => {
|
|
||||||
return costItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
|
||||||
}, [costItems]);
|
|
||||||
|
|
||||||
// Check if all required fields are filled
|
|
||||||
const isFormValid = useMemo(() => {
|
|
||||||
const hasProposalDoc = proposalDocument !== null;
|
|
||||||
const hasValidCostItems = costItems.length > 0 &&
|
|
||||||
costItems.every(item => item.description.trim() !== '' && item.amount > 0);
|
|
||||||
const hasTimeline = timelineMode === 'date'
|
|
||||||
? expectedCompletionDate !== ''
|
|
||||||
: numberOfDays !== '' && parseInt(numberOfDays) > 0;
|
|
||||||
const hasComments = dealerComments.trim().length > 0;
|
|
||||||
|
|
||||||
return hasProposalDoc && hasValidCostItems && hasTimeline && hasComments;
|
|
||||||
}, [proposalDocument, costItems, timelineMode, expectedCompletionDate, numberOfDays, dealerComments]);
|
|
||||||
|
|
||||||
const handleProposalDocChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
// 1. Check file size
|
|
||||||
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
|
||||||
if (file.size > maxSizeBytes) {
|
|
||||||
toast.error(`File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`);
|
|
||||||
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Validate file type (User requested: Keep strictly to pdf, doc, docx + Intersection with system policy)
|
|
||||||
const hardcodedAllowed = ['.pdf', '.doc', '.docx'];
|
|
||||||
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
|
||||||
const simpleExt = file.name.split('.').pop()?.toLowerCase() || '';
|
|
||||||
|
|
||||||
if (!hardcodedAllowed.includes(fileExtension) || !documentPolicy.allowedFileTypes.includes(simpleExt)) {
|
|
||||||
toast.error('Please upload a valid PDF, DOC, or DOCX file as per system policy');
|
|
||||||
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProposalDocument(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOtherDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = Array.from(e.target.files || []);
|
|
||||||
const validFiles: File[] = [];
|
|
||||||
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
// 1. Check file size
|
|
||||||
if (file.size > maxSizeBytes) {
|
|
||||||
toast.error(`"${file.name}" exceeds ${documentPolicy.maxFileSizeMB}MB limit and was not added.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check file type
|
|
||||||
const fileExtension = file.name.split('.').pop()?.toLowerCase() || '';
|
|
||||||
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
|
||||||
toast.error(`"${file.name}" has an unsupported file type and was not added.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
validFiles.push(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
setOtherDocuments(prev => [...prev, ...validFiles]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset input so searching the same file again triggers change event
|
|
||||||
if (otherDocsInputRef.current) otherDocsInputRef.current.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCostItem = () => {
|
|
||||||
setCostItems(prev => [
|
|
||||||
...prev,
|
|
||||||
{ id: Date.now().toString(), description: '', amount: 0 },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveCostItem = (id: string) => {
|
|
||||||
if (costItems.length > 1) {
|
|
||||||
setCostItems(prev => prev.filter(item => item.id !== id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCostItemChange = (id: string, field: 'description' | 'amount', value: string | number) => {
|
|
||||||
setCostItems(prev =>
|
|
||||||
prev.map(item =>
|
|
||||||
item.id === id
|
|
||||||
? { ...item, [field]: field === 'amount' ? parseFloat(value.toString()) || 0 : value }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveOtherDoc = (index: number) => {
|
|
||||||
setOtherDocuments(prev => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!isFormValid) {
|
|
||||||
toast.error('Please fill all required fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate final completion date if using days mode
|
|
||||||
let finalCompletionDate: string = expectedCompletionDate || '';
|
|
||||||
if (timelineMode === 'days' && numberOfDays) {
|
|
||||||
const days = parseInt(numberOfDays);
|
|
||||||
const date = new Date();
|
|
||||||
date.setDate(date.getDate() + days);
|
|
||||||
const isoString = date.toISOString();
|
|
||||||
finalCompletionDate = isoString.split('T')[0] as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
await onSubmit({
|
|
||||||
proposalDocument,
|
|
||||||
costBreakup: costItems.filter(item => item.description.trim() !== '' && item.amount > 0),
|
|
||||||
expectedCompletionDate: finalCompletionDate,
|
|
||||||
otherDocuments,
|
|
||||||
dealerComments,
|
|
||||||
});
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to submit proposal:', error);
|
|
||||||
toast.error('Failed to submit proposal. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
// Cleanup preview URL if exists and it's a blob
|
|
||||||
if (previewDoc?.fileUrl && previewDoc.fileUrl.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(previewDoc.fileUrl);
|
|
||||||
}
|
|
||||||
setPreviewDoc(null);
|
|
||||||
setProposalDocument(null);
|
|
||||||
setCostItems([{ id: '1', description: '', amount: 0 }]);
|
|
||||||
setTimelineMode('date');
|
|
||||||
setExpectedCompletionDate('');
|
|
||||||
setNumberOfDays('');
|
|
||||||
setOtherDocuments([]);
|
|
||||||
setDealerComments('');
|
|
||||||
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
|
|
||||||
if (otherDocsInputRef.current) otherDocsInputRef.current.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!submitting) {
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get minimum date (today)
|
|
||||||
const minDate = new Date().toISOString().split('T')[0];
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4">
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-xl lg:text-2xl">
|
|
||||||
<Upload className="w-5 h-5 lg:w-6 lg:h-6 text-[--re-green]" />
|
|
||||||
Dealer Proposal Submission
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm lg:text-base">
|
|
||||||
Step 1: Upload proposal and planning details
|
|
||||||
</DialogDescription>
|
|
||||||
<div className="space-y-1 mt-2 text-xs lg:text-sm text-gray-600">
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
|
||||||
<div>
|
|
||||||
<strong>Dealer:</strong> {dealerName}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Activity:</strong> {activityName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1">
|
|
||||||
Please upload proposal document, provide cost breakdown, timeline, and detailed comments.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4">
|
|
||||||
|
|
||||||
{/* Previous Proposal Reference Section */}
|
|
||||||
{previousProposalData && (
|
|
||||||
<div className="mb-6 mx-1">
|
|
||||||
<div
|
|
||||||
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
|
|
||||||
onClick={() => setShowPreviousProposal(!showPreviousProposal)}
|
|
||||||
>
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4 text-amber-700" />
|
|
||||||
<span className="text-sm font-semibold text-amber-900">Reference: Previous Proposal Details</span>
|
|
||||||
<Badge variant="secondary" className="bg-amber-200 text-amber-800 text-[10px]">
|
|
||||||
₹{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-amber-700">
|
|
||||||
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showPreviousProposal && (
|
|
||||||
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
|
|
||||||
{/* Header Info: Date & Document */}
|
|
||||||
<div className="flex flex-wrap gap-4 text-xs">
|
|
||||||
{previousProposalData.expectedCompletionDate && (
|
|
||||||
<div className="flex items-center gap-1.5 text-gray-700">
|
|
||||||
<Calendar className="w-3.5 h-3.5 text-gray-500" />
|
|
||||||
<span className="font-medium">Expected Completion:</span>
|
|
||||||
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{previousProposalData.documentUrl && (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{canPreview(previousProposalData.documentUrl) ? (
|
|
||||||
<>
|
|
||||||
<Eye className="w-3.5 h-3.5 text-blue-500" />
|
|
||||||
<a
|
|
||||||
href={previousProposalData.documentUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
|
||||||
>
|
|
||||||
View Previous Document
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-3.5 h-3.5 text-blue-500" />
|
|
||||||
<a
|
|
||||||
href={previousProposalData.documentUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Download Previous Document
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Additional/Supporting Documents */}
|
|
||||||
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
|
|
||||||
<div className="w-full pt-2 border-t border-amber-200/50">
|
|
||||||
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
|
|
||||||
<FileText className="w-3 h-3" />
|
|
||||||
Supporting Documents
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
|
||||||
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
|
|
||||||
<DocumentCard
|
|
||||||
key={idx}
|
|
||||||
document={{
|
|
||||||
documentId: doc.documentId || doc.id || '',
|
|
||||||
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
|
|
||||||
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
|
|
||||||
uploadedAt: doc.uploadedAt || new Date().toISOString()
|
|
||||||
}}
|
|
||||||
onPreview={canPreview(doc.originalFileName || doc.fileName || doc.name || '') ? () => handlePreviewExisting(doc) : undefined}
|
|
||||||
onDownload={async (id) => {
|
|
||||||
if (id) {
|
|
||||||
await downloadDocument(id);
|
|
||||||
} else {
|
|
||||||
let downloadUrl = doc.storageUrl || doc.documentUrl;
|
|
||||||
if (downloadUrl && !downloadUrl.startsWith('http')) {
|
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
||||||
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
|
|
||||||
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
||||||
}
|
|
||||||
if (downloadUrl) window.open(downloadUrl, '_blank');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Previous Cost Breakup (handling both costBreakup and costItems) */}
|
|
||||||
{(previousProposalData.costItems || previousProposalData.costBreakup) && (previousProposalData.costItems || previousProposalData.costBreakup).length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-xs font-semibold text-gray-700 mb-2">Previous Cost Breakdown:</p>
|
|
||||||
<div className="border rounded-md overflow-hidden text-xs">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 text-gray-600">
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 font-medium">Description</th>
|
|
||||||
<th className="p-2 font-medium text-right">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{(previousProposalData.costItems || previousProposalData.costBreakup).map((item: any, idx: number) => (
|
|
||||||
<tr key={idx} className="bg-white">
|
|
||||||
<td className="p-2 text-gray-800">{item.description}</td>
|
|
||||||
<td className="p-2 text-right text-gray-800 font-medium">
|
|
||||||
₹{Number(item.amount).toLocaleString('en-IN')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
<tr className="bg-gray-50 font-bold">
|
|
||||||
<td className="p-2 text-gray-900">Total</td>
|
|
||||||
<td className="p-2 text-right text-gray-900">
|
|
||||||
₹{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Previous Comments */}
|
|
||||||
{(previousProposalData.comments || previousProposalData.dealerComments) && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-gray-700 mb-1">Previous Comments:</p>
|
|
||||||
<div className="bg-white border rounded p-2 text-xs text-gray-600 italic">
|
|
||||||
"{previousProposalData.comments || previousProposalData.dealerComments}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
|
|
||||||
{/* Left Column - Documents */}
|
|
||||||
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
|
||||||
{/* Proposal Document Section */}
|
|
||||||
<div className="space-y-2 lg:space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-base lg:text-lg">Proposal Document</h3>
|
|
||||||
<Badge variant="outline" className="text-xs border-red-500 text-red-700 bg-red-50 font-medium">Required</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm lg:text-base font-semibold flex items-center gap-2">
|
|
||||||
Proposal Document *
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs lg:text-sm text-gray-600 mb-2">
|
|
||||||
Detailed proposal with activity details and requested information
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className={`border-2 border-dashed rounded-lg p-3 lg:p-4 transition-all duration-200 ${
|
|
||||||
proposalDocument
|
|
||||||
? 'border-green-500 bg-green-50 hover:border-green-600'
|
|
||||||
: 'border-gray-300 hover:border-blue-500 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={proposalDocInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={['.pdf', '.doc', '.docx'].filter(ext => documentPolicy.allowedFileTypes.includes(ext.replace('.', ''))).join(',')}
|
|
||||||
className="hidden"
|
|
||||||
id="proposalDoc"
|
|
||||||
onChange={handleProposalDocChange}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="proposalDoc"
|
|
||||||
className="cursor-pointer flex flex-col items-center gap-2"
|
|
||||||
>
|
|
||||||
{proposalDocument ? (
|
|
||||||
<div className="flex flex-col items-center gap-2 w-full">
|
|
||||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
|
||||||
<div className="flex flex-col items-center gap-1 w-full max-w-full px-2">
|
|
||||||
<span className="text-sm font-semibold text-green-700 break-words text-center w-full max-w-full">
|
|
||||||
{proposalDocument.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-green-600 mb-2">
|
|
||||||
Document selected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{canPreviewFile(proposalDocument) && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePreviewFile(proposalDocument)}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5 mr-1" />
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDownloadFile(proposalDocument)}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5 mr-1" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="w-8 h-8 text-gray-400" />
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
Click to upload proposal (PDF, DOC, DOCX)
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other Supporting Documents Section */}
|
|
||||||
<div className="space-y-2 lg:space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-base lg:text-lg">Other Supporting Documents</h3>
|
|
||||||
<Badge variant="outline" className="text-xs border-gray-300 text-gray-600 bg-gray-50 font-medium">Optional</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="flex items-center gap-2 text-sm lg:text-base font-semibold">
|
|
||||||
Additional Documents
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs lg:text-sm text-gray-600 mb-2">
|
|
||||||
Any other supporting documents (invoices, receipts, photos, etc.)
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className={`border-2 border-dashed rounded-lg p-3 lg:p-4 transition-all duration-200 ${
|
|
||||||
otherDocuments.length > 0
|
|
||||||
? 'border-blue-500 bg-blue-50 hover:border-blue-600'
|
|
||||||
: 'border-gray-300 hover:border-blue-500 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={otherDocsInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
|
|
||||||
className="hidden"
|
|
||||||
id="otherDocs"
|
|
||||||
onChange={handleOtherDocsChange}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="otherDocs"
|
|
||||||
className="cursor-pointer flex flex-col items-center gap-2"
|
|
||||||
>
|
|
||||||
{otherDocuments.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="w-8 h-8 text-blue-600" />
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<span className="text-sm font-semibold text-blue-700">
|
|
||||||
{otherDocuments.length} document{otherDocuments.length !== 1 ? 's' : ''} selected
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-blue-600">
|
|
||||||
Click to add more documents
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="w-8 h-8 text-gray-400" />
|
|
||||||
<span className="text-sm text-gray-600 text-center">
|
|
||||||
Click to upload additional documents
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-400">
|
|
||||||
Max {documentPolicy.maxFileSizeMB}MB | {documentPolicy.allowedFileTypes.join(', ').toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{otherDocuments.length > 0 && (
|
|
||||||
<div className="mt-2 lg:mt-3 space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
|
||||||
<p className="text-xs font-medium text-gray-600 mb-1">
|
|
||||||
Selected Documents ({otherDocuments.length}):
|
|
||||||
</p>
|
|
||||||
{otherDocuments.map((file, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-start justify-between bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 p-2 lg:p-3 rounded-lg text-xs lg:text-sm shadow-sm hover:shadow-md transition-shadow w-full"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2 flex-1 min-w-0 pr-2">
|
|
||||||
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<span className="text-gray-800 font-medium break-words break-all">
|
|
||||||
{file.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
|
||||||
{canPreviewFile(file) && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
|
||||||
onClick={() => handlePreviewFile(file)}
|
|
||||||
title="Preview file"
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
|
||||||
onClick={() => handleDownloadFile(file)}
|
|
||||||
title="Download file"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
|
||||||
onClick={() => handleRemoveOtherDoc(index)}
|
|
||||||
title="Remove document"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Planning & Budget */}
|
|
||||||
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
|
||||||
{/* Cost Breakup Section */}
|
|
||||||
<div className="space-y-2 lg:space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-base lg:text-lg">Cost Breakup</h3>
|
|
||||||
<Badge variant="outline" className="text-xs border-red-500 text-red-700 bg-red-50 font-medium">Required</Badge>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddCostItem}
|
|
||||||
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3 lg:w-4 lg:h-4 mr-1" />
|
|
||||||
<span className="hidden sm:inline">Add Item</span>
|
|
||||||
<span className="sm:hidden">Add</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 lg:space-y-2 max-h-[200px] lg:max-h-[180px] overflow-y-auto">
|
|
||||||
{costItems.map((item) => (
|
|
||||||
<div key={item.id} className="flex gap-2 items-start w-full">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<Input
|
|
||||||
placeholder="Item description (e.g., Banner printing, Event setup)"
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleCostItemChange(item.id, 'description', e.target.value)
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-32 lg:w-36 flex-shrink-0">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Amount"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={item.amount || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleCostItemChange(item.id, 'amount', e.target.value)
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="mt-0.5 hover:bg-red-100 hover:text-red-700 flex-shrink-0"
|
|
||||||
onClick={() => handleRemoveCostItem(item.id)}
|
|
||||||
disabled={costItems.length === 1}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="border-2 border-gray-300 rounded-lg p-3 lg:p-4 bg-white">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<IndianRupee className="w-4 h-4 lg:w-5 lg:h-5 text-gray-700" />
|
|
||||||
<span className="font-semibold text-sm lg:text-base text-gray-900">Estimated Budget</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xl lg:text-2xl font-bold text-gray-900">
|
|
||||||
₹{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline for Closure Section */}
|
|
||||||
<div className="space-y-2 lg:space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-base lg:text-lg">Timeline for Closure</h3>
|
|
||||||
<Badge variant="outline" className="text-xs border-red-500 text-red-700 bg-red-50 font-medium">Required</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTimelineMode('date')}
|
|
||||||
className={
|
|
||||||
timelineMode === 'date'
|
|
||||||
? 'bg-[#2d4a3e] hover:bg-[#1f3329] text-white'
|
|
||||||
: 'border-2 hover:bg-gray-50'
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Calendar className="w-4 h-4 mr-1" />
|
|
||||||
Specific Date
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTimelineMode('days')}
|
|
||||||
className={
|
|
||||||
timelineMode === 'days'
|
|
||||||
? 'bg-[#2d4a3e] hover:bg-[#1f3329] text-white'
|
|
||||||
: 'border-2 hover:bg-gray-50'
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Number of Days
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{timelineMode === 'date' ? (
|
|
||||||
<div className="w-full">
|
|
||||||
<Label className="text-xs lg:text-sm font-medium mb-1.5 lg:mb-2 block">
|
|
||||||
Expected Completion Date
|
|
||||||
</Label>
|
|
||||||
<CustomDatePicker
|
|
||||||
value={expectedCompletionDate || null}
|
|
||||||
onChange={(date) => setExpectedCompletionDate(date || '')}
|
|
||||||
minDate={minDate}
|
|
||||||
placeholderText="dd/mm/yyyy"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-full">
|
|
||||||
<Label className="text-xs lg:text-sm font-medium mb-1.5 lg:mb-2 block">
|
|
||||||
Number of Days
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Enter number of days"
|
|
||||||
min="1"
|
|
||||||
value={numberOfDays}
|
|
||||||
onChange={(e) => setNumberOfDays(e.target.value)}
|
|
||||||
className="h-9 lg:h-10 w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dealer Comments Section */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="dealerComments" className="text-sm lg:text-base font-semibold flex items-center gap-2">
|
|
||||||
Dealer Comments / Details *
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="dealerComments"
|
|
||||||
placeholder="Provide detailed comments about this claim request, including any special considerations, execution details, or additional information..."
|
|
||||||
value={dealerComments}
|
|
||||||
onChange={(e) => setDealerComments(e.target.value)}
|
|
||||||
className="min-h-[80px] lg:min-h-[100px] text-sm w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">{dealerComments.length} characters</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Full Width Sections */}
|
|
||||||
|
|
||||||
{/* Warning Message */}
|
|
||||||
{!isFormValid && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 lg:p-4 flex items-start gap-2 lg:gap-3 lg:col-span-2">
|
|
||||||
<CircleAlert className="w-4 h-4 lg:w-5 lg:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="text-xs lg:text-sm text-amber-800">
|
|
||||||
<p className="font-semibold mb-1">Missing Required Information</p>
|
|
||||||
<p>
|
|
||||||
Please ensure proposal document, cost breakup, timeline, and dealer comments are provided before submitting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end flex-shrink-0 pt-3 lg:pt-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
className="border-2"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!isFormValid || submitting}
|
|
||||||
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white disabled:bg-gray-300 disabled:text-gray-500"
|
|
||||||
>
|
|
||||||
{submitting ? 'Submitting...' : 'Submit Documents'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
{/* Standardized File Preview */}
|
|
||||||
{previewDoc && (
|
|
||||||
<FilePreview
|
|
||||||
fileName={previewDoc.fileName}
|
|
||||||
fileType={previewDoc.fileType}
|
|
||||||
fileUrl={previewDoc.fileUrl}
|
|
||||||
fileSize={previewDoc.fileSize}
|
|
||||||
attachmentId={previewDoc.documentId}
|
|
||||||
onDownload={downloadDocument}
|
|
||||||
open={!!previewDoc}
|
|
||||||
onClose={() => setPreviewDoc(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
.dept-lead-io-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.dept-lead-io-modal {
|
|
||||||
width: 95vw !important;
|
|
||||||
max-width: 95vw !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet and small desktop */
|
|
||||||
@media (min-width: 641px) and (max-width: 1023px) {
|
|
||||||
.dept-lead-io-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Large screens - fixed max-width for better readability */
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.dept-lead-io-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 1000px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extra large screens */
|
|
||||||
@media (min-width: 1536px) {
|
|
||||||
.dept-lead-io-modal {
|
|
||||||
width: 90vw !important;
|
|
||||||
max-width: 1000px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,331 +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';
|
|
||||||
import './DeptLeadIOApprovalModal.css';
|
|
||||||
|
|
||||||
interface DeptLeadIOApprovalModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApprove: (data: {
|
|
||||||
ioNumber: string;
|
|
||||||
comments: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
onReject: (comments: string) => Promise<void>;
|
|
||||||
requestTitle?: string;
|
|
||||||
requestId?: string;
|
|
||||||
// Pre-filled IO data from IO table
|
|
||||||
preFilledIONumber?: string;
|
|
||||||
preFilledBlockedAmount?: number;
|
|
||||||
preFilledRemainingBalance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeptLeadIOApprovalModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onApprove,
|
|
||||||
onReject,
|
|
||||||
requestTitle,
|
|
||||||
requestId: _requestId,
|
|
||||||
preFilledIONumber,
|
|
||||||
preFilledBlockedAmount,
|
|
||||||
preFilledRemainingBalance,
|
|
||||||
}: DeptLeadIOApprovalModalProps) {
|
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
|
|
||||||
const [comments, setComments] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
// Get IO number from props (read-only, from IO table)
|
|
||||||
const ioNumber = preFilledIONumber || '';
|
|
||||||
|
|
||||||
// Reset form when modal opens/closes
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setComments('');
|
|
||||||
setActionType('approve');
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const commentsChars = comments.length;
|
|
||||||
const maxCommentsChars = 500;
|
|
||||||
|
|
||||||
// Validate form
|
|
||||||
const isFormValid = useMemo(() => {
|
|
||||||
if (actionType === 'reject') {
|
|
||||||
return comments.trim().length > 0;
|
|
||||||
}
|
|
||||||
// For approve, need IO number (from table) and comments
|
|
||||||
return (
|
|
||||||
ioNumber.trim().length > 0 && // IO number must exist from IO table
|
|
||||||
comments.trim().length > 0
|
|
||||||
);
|
|
||||||
}, [actionType, ioNumber, comments]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!isFormValid) {
|
|
||||||
if (actionType === 'approve') {
|
|
||||||
if (!ioNumber.trim()) {
|
|
||||||
toast.error('IO number is required. Please block amount from IO tab first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!comments.trim()) {
|
|
||||||
toast.error('Please provide comments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
if (actionType === 'approve') {
|
|
||||||
await onApprove({
|
|
||||||
ioNumber: ioNumber.trim(),
|
|
||||||
comments: comments.trim(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onReject(comments.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to ${actionType} request:`, error);
|
|
||||||
toast.error(`Failed to ${actionType} request. Please try again.`);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setActionType('approve');
|
|
||||||
setComments('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!submitting) {
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="dept-lead-io-modal overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader className="flex-shrink-0 px-6 pt-6 pb-3">
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 mb-2">
|
|
||||||
<div className="p-1.5 lg:p-2 rounded-lg bg-green-100">
|
|
||||||
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<DialogTitle className="font-semibold text-lg lg:text-xl">
|
|
||||||
Review and Approve
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs lg:text-sm mt-1">
|
|
||||||
Review IO details and provide your approval comments
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Request Info Card */}
|
|
||||||
<div className="space-y-2 lg:space-y-3 p-3 lg:p-4 bg-gray-50 rounded-lg border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium text-sm lg:text-base text-gray-900">Workflow Step:</span>
|
|
||||||
<Badge variant="outline" className="font-mono text-xs">Step 3</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-sm lg:text-base text-gray-900">Title:</span>
|
|
||||||
<p className="text-xs lg:text-sm text-gray-700 mt-1">{requestTitle || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-sm lg:text-base text-gray-900">Action:</span>
|
|
||||||
<Badge className="bg-green-100 text-green-800 border-green-200 text-xs">
|
|
||||||
<CircleCheckBig className="w-3 h-3 mr-1" />
|
|
||||||
APPROVE
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
|
|
||||||
<div className="space-y-3 lg:space-y-4">
|
|
||||||
{/* Action Toggle Buttons */}
|
|
||||||
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActionType('approve')}
|
|
||||||
className={`flex-1 text-sm lg:text-base ${
|
|
||||||
actionType === 'approve'
|
|
||||||
? 'bg-green-600 text-white shadow-sm'
|
|
||||||
: 'text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
variant={actionType === 'approve' ? 'default' : 'ghost'}
|
|
||||||
>
|
|
||||||
<CircleCheckBig className="w-4 h-4 mr-1" />
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActionType('reject')}
|
|
||||||
className={`flex-1 text-sm lg:text-base ${
|
|
||||||
actionType === 'reject'
|
|
||||||
? 'bg-red-600 text-white shadow-sm'
|
|
||||||
: 'text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
|
|
||||||
>
|
|
||||||
<CircleX className="w-4 h-4 mr-1" />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content Area - Two Column Layout on Large Screens */}
|
|
||||||
<div className="space-y-3 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6">
|
|
||||||
{/* Left Column - IO Organisation Details (Only shown when approving) */}
|
|
||||||
{actionType === 'approve' && (
|
|
||||||
<div className="p-3 lg:p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
|
||||||
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IO Number - Read-only from IO table */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
IO Number <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="ioNumber"
|
|
||||||
value={ioNumber || '—'}
|
|
||||||
disabled
|
|
||||||
readOnly
|
|
||||||
className="bg-gray-100 h-8 lg:h-9 cursor-not-allowed text-xs lg:text-sm"
|
|
||||||
/>
|
|
||||||
{!ioNumber && (
|
|
||||||
<p className="text-xs text-red-600 mt-1">
|
|
||||||
⚠️ IO number not found. Please block amount from IO tab first.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{ioNumber && (
|
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
|
||||||
✓ Loaded from IO table
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IO Balance Information - Read-only */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{/* Blocked Amount Display */}
|
|
||||||
{preFilledBlockedAmount !== undefined && preFilledBlockedAmount > 0 && (
|
|
||||||
<div className="p-2 bg-green-50 border border-green-200 rounded">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs font-semibold text-gray-700">Blocked Amount:</span>
|
|
||||||
<span className="text-xs lg:text-sm font-bold text-green-700 mt-1">
|
|
||||||
₹{preFilledBlockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Remaining Balance Display */}
|
|
||||||
{preFilledRemainingBalance !== undefined && preFilledRemainingBalance !== null && (
|
|
||||||
<div className="p-2 bg-blue-50 border border-blue-200 rounded">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs font-semibold text-gray-700">Remaining Balance:</span>
|
|
||||||
<span className="text-xs lg:text-sm font-bold text-blue-700 mt-1">
|
|
||||||
₹{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right Column - Comments & Remarks */}
|
|
||||||
<div className={`space-y-1.5 ${actionType === 'approve' ? '' : 'lg:col-span-2'}`}>
|
|
||||||
<Label htmlFor="comment" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
Comments & Remarks <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="comment"
|
|
||||||
placeholder={
|
|
||||||
actionType === 'approve'
|
|
||||||
? 'Enter your approval comments and any conditions or notes...'
|
|
||||||
: 'Enter detailed reasons for rejection...'
|
|
||||||
}
|
|
||||||
value={comments}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
if (value.length <= maxCommentsChars) {
|
|
||||||
setComments(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
rows={4}
|
|
||||||
className="text-xs lg:text-sm min-h-[80px] lg:min-h-[100px] resize-none"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<TriangleAlert className="w-3 h-3" />
|
|
||||||
Required and visible to all
|
|
||||||
</div>
|
|
||||||
<span>{commentsChars}/{maxCommentsChars}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-shrink-0 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pb-6 pt-3 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
className="text-sm lg:text-base"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!isFormValid || submitting}
|
|
||||||
className={`text-sm lg:text-base ${
|
|
||||||
actionType === 'approve'
|
|
||||||
? 'bg-green-600 hover:bg-green-700'
|
|
||||||
: 'bg-red-600 hover:bg-red-700'
|
|
||||||
} text-white`}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CircleCheckBig className="w-4 h-4 mr-2" />
|
|
||||||
{actionType === 'approve' ? 'Approve Request' : 'Reject Request'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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] lg:max-w-[800px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<DollarSign className="w-5 h-5 text-green-600" />
|
|
||||||
Edit Claim Amount
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Update the claim amount. This will be recorded in the activity log.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
{/* Current Amount Display */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
||||||
<Label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1 block">
|
|
||||||
Current Claim Amount
|
|
||||||
</Label>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
|
||||||
{formatCurrency(currentAmount)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* New Amount Input */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="new-amount" className="text-sm font-medium">
|
|
||||||
New Claim Amount <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 font-semibold">
|
|
||||||
{currency}
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
id="new-amount"
|
|
||||||
type="text"
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => handleAmountChange(e.target.value)}
|
|
||||||
placeholder="Enter amount"
|
|
||||||
className="pl-8 text-lg font-semibold"
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-red-600">
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount Difference */}
|
|
||||||
{amount && !isNaN(parseFloat(amount)) && parseFloat(amount) !== currentAmount && (
|
|
||||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-gray-700">Difference:</span>
|
|
||||||
<span className={`font-semibold ${
|
|
||||||
parseFloat(amount) > currentAmount ? 'text-green-700' : 'text-red-700'
|
|
||||||
}`}>
|
|
||||||
{parseFloat(amount) > currentAmount ? '+' : ''}
|
|
||||||
{formatCurrency(parseFloat(amount) - currentAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Warning Message */}
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="text-xs text-amber-800">
|
|
||||||
<p className="font-semibold mb-1">Important:</p>
|
|
||||||
<ul className="space-y-1 list-disc list-inside">
|
|
||||||
<li>Ensure the new amount is verified and approved</li>
|
|
||||||
<li>This change will be logged in the activity trail</li>
|
|
||||||
<li>Budget blocking (IO) may need to be adjusted</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={submitting || !amount || parseFloat(amount) <= 0}
|
|
||||||
className="bg-green-600 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
{submitting ? 'Updating...' : 'Update Amount'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 lg:max-w-[1000px] max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
|
|
||||||
<Mail className="w-5 h-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<DialogTitle className="text-lg leading-none font-semibold">
|
|
||||||
Email Notification Template
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm">
|
|
||||||
Step {stepNumber}: {stepName}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Email Header Section */}
|
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4 border border-blue-200">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<User className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-xs text-gray-600">To:</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">{recipientEmail}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Mail className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-xs text-gray-600">Subject:</p>
|
|
||||||
<p className="text-sm font-semibold text-gray-900">{finalSubject}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email Body Section */}
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Company Header */}
|
|
||||||
<div className="flex items-center gap-2 pb-3 border-b border-gray-200">
|
|
||||||
<Building className="w-5 h-5 text-purple-600" />
|
|
||||||
<span className="font-semibold text-gray-900">Royal Enfield</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email Content */}
|
|
||||||
<div className="prose prose-sm max-w-none">
|
|
||||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 leading-relaxed bg-transparent p-0 border-0">
|
|
||||||
{finalEmailBody}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="pt-3 border-t border-gray-200">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
||||||
<Calendar className="w-3 h-3" />
|
|
||||||
<span>Automated email • Royal Enfield Claims Portal</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badges */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge className="bg-blue-50 text-blue-700 border-blue-200">
|
|
||||||
Step {stepNumber}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-purple-50 text-purple-700 border-purple-200">
|
|
||||||
Auto-triggered
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
className="h-9"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 mr-2" />
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* InitiatorActionModal Component
|
|
||||||
* Modal for Initiator to take action on a returned/rejected request
|
|
||||||
* Actions: Reopen, Request Revised Quotation, Cancel
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
|
||||||
RefreshCw,
|
|
||||||
MessageSquare,
|
|
||||||
FileEdit,
|
|
||||||
XOctagon,
|
|
||||||
AlertTriangle,
|
|
||||||
Loader2
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
interface InitiatorActionModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onAction: (action: 'REOPEN' | 'REVISE' | 'CANCEL', comments: string) => Promise<void>;
|
|
||||||
requestTitle?: string;
|
|
||||||
requestId?: string;
|
|
||||||
defaultAction?: 'REOPEN' | 'REVISE' | 'CANCEL';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InitiatorActionModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onAction,
|
|
||||||
requestTitle = 'Request',
|
|
||||||
requestId: _requestId,
|
|
||||||
defaultAction,
|
|
||||||
}: InitiatorActionModalProps) {
|
|
||||||
const [comments, setComments] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [selectedAction, setSelectedAction] = useState<'REOPEN' | 'REVISE' | 'CANCEL' | null>(defaultAction || null);
|
|
||||||
|
|
||||||
// Update selectedAction when defaultAction changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (defaultAction) {
|
|
||||||
setSelectedAction(defaultAction);
|
|
||||||
}
|
|
||||||
}, [defaultAction]);
|
|
||||||
|
|
||||||
const actions = [
|
|
||||||
{
|
|
||||||
id: 'REOPEN',
|
|
||||||
label: 'Reopen & Resubmit',
|
|
||||||
description: 'Resubmit the request to the department head for approval.',
|
|
||||||
icon: <RefreshCw className="w-5 h-5 text-blue-600" />,
|
|
||||||
color: 'blue',
|
|
||||||
variant: 'default' as const
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'REVISE',
|
|
||||||
label: 'Request Revised Quotation',
|
|
||||||
description: 'Ask dealer to submit a new proposal/quotation.',
|
|
||||||
icon: <FileEdit className="w-5 h-5 text-amber-600" />,
|
|
||||||
color: 'amber',
|
|
||||||
variant: 'default' as const
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'CANCEL',
|
|
||||||
label: 'Cancel Request',
|
|
||||||
description: 'Permanently close and cancel this request.',
|
|
||||||
icon: <XOctagon className="w-5 h-5 text-red-600" />,
|
|
||||||
color: 'red',
|
|
||||||
variant: 'destructive' as const
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleActionClick = (actionId: any) => {
|
|
||||||
setSelectedAction(actionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!selectedAction) {
|
|
||||||
toast.error('Please select an action');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!comments.trim()) {
|
|
||||||
toast.error('Please provide a reason or comments for this action');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
await onAction(selectedAction, comments);
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to perform initiator action:', error);
|
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Action failed. Please try again.';
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setComments('');
|
|
||||||
setSelectedAction(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!submitting) {
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl">Action Required: {requestTitle}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This request has been returned to you. Please select how you would like to proceed.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="py-4 space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{actions.map((action) => (
|
|
||||||
<div
|
|
||||||
key={action.id}
|
|
||||||
onClick={() => handleActionClick(action.id)}
|
|
||||||
className={`
|
|
||||||
cursor-pointer p-4 border-2 rounded-xl transition-all duration-200
|
|
||||||
${selectedAction === action.id
|
|
||||||
? `border-${action.color}-600 bg-${action.color}-50 shadow-sm`
|
|
||||||
: 'border-gray-100 hover:border-gray-200 hover:bg-gray-50'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className={`p-2 rounded-lg bg-white border border-gray-100`}>
|
|
||||||
{action.icon}
|
|
||||||
</div>
|
|
||||||
<h4 className="font-bold text-sm text-gray-900">{action.label}</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 leading-relaxed">
|
|
||||||
{action.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<MessageSquare className="w-4 h-4 text-gray-500" />
|
|
||||||
Comments / Reason
|
|
||||||
</h3>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Provide a detailed reason for your decision..."
|
|
||||||
value={comments}
|
|
||||||
onChange={(e) => setComments(e.target.value)}
|
|
||||||
className="min-h-[120px] text-sm resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedAction === 'CANCEL' && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-100 rounded-lg flex items-start gap-3">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="text-xs text-red-800">
|
|
||||||
<p className="font-bold mb-1">Warning: Irreversible Action</p>
|
|
||||||
<p>Cancelling this request will permanently close it. This action cannot be undone.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="border-t pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!selectedAction || !comments.trim() || submitting}
|
|
||||||
className={`
|
|
||||||
min-w-[120px]
|
|
||||||
${selectedAction === 'CANCEL' ? 'bg-red-600 hover:bg-red-700' : 'bg-purple-600 hover:bg-purple-700'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Processing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Confirm Action'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,782 +0,0 @@
|
|||||||
/**
|
|
||||||
* InitiatorProposalApprovalModal Component
|
|
||||||
* Modal for Step 2: Requestor Evaluation & Confirmation
|
|
||||||
* Allows initiator to review dealer's proposal and approve/reject
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
FileText,
|
|
||||||
IndianRupee,
|
|
||||||
Calendar,
|
|
||||||
MessageSquare,
|
|
||||||
Download,
|
|
||||||
Eye,
|
|
||||||
Plus,
|
|
||||||
Minus,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
|
||||||
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
|
|
||||||
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
|
|
||||||
import '@/components/common/FilePreview/FilePreview.css';
|
|
||||||
import './DealerProposalModal.css';
|
|
||||||
|
|
||||||
interface CostItem {
|
|
||||||
id: string;
|
|
||||||
description: string;
|
|
||||||
amount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProposalData {
|
|
||||||
proposalDocument?: {
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
costBreakup: CostItem[];
|
|
||||||
expectedCompletionDate: string;
|
|
||||||
otherDocuments?: Array<{
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
id?: string;
|
|
||||||
}>;
|
|
||||||
dealerComments: string;
|
|
||||||
submittedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InitiatorProposalApprovalModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApprove: (comments: string) => Promise<void>;
|
|
||||||
onReject: (comments: string) => Promise<void>;
|
|
||||||
onRequestRevision?: (comments: string) => Promise<void>;
|
|
||||||
proposalData: ProposalData | null;
|
|
||||||
dealerName?: string;
|
|
||||||
activityName?: string;
|
|
||||||
requestId?: string;
|
|
||||||
request?: any; // Request object to check IO blocking status
|
|
||||||
previousProposalData?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InitiatorProposalApprovalModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onApprove,
|
|
||||||
onReject,
|
|
||||||
onRequestRevision,
|
|
||||||
proposalData,
|
|
||||||
dealerName = 'Dealer',
|
|
||||||
activityName = 'Activity',
|
|
||||||
requestId: _requestId, // Prefix with _ to indicate intentionally unused
|
|
||||||
request,
|
|
||||||
previousProposalData,
|
|
||||||
}: InitiatorProposalApprovalModalProps) {
|
|
||||||
const [comments, setComments] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
|
|
||||||
const [showPreviousProposal, setShowPreviousProposal] = useState(false);
|
|
||||||
|
|
||||||
// Check if IO is blocked (IO blocking moved to Requestor Evaluation level)
|
|
||||||
const internalOrder = request?.internalOrder || request?.internal_order;
|
|
||||||
const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
|
||||||
const isIOBlocked = ioBlockedAmount > 0;
|
|
||||||
const [previewDoc, setPreviewDoc] = useState<{
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
type?: string;
|
|
||||||
size?: number;
|
|
||||||
id?: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Calculate total budget
|
|
||||||
const totalBudget = useMemo(() => {
|
|
||||||
if (!proposalData?.costBreakup) return 0;
|
|
||||||
|
|
||||||
// Ensure costBreakup is an array
|
|
||||||
const costBreakup = Array.isArray(proposalData.costBreakup)
|
|
||||||
? proposalData.costBreakup
|
|
||||||
: (typeof proposalData.costBreakup === 'string'
|
|
||||||
? JSON.parse(proposalData.costBreakup)
|
|
||||||
: []);
|
|
||||||
|
|
||||||
if (!Array.isArray(costBreakup)) return 0;
|
|
||||||
|
|
||||||
return costBreakup.reduce((sum: number, item: any) => {
|
|
||||||
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
|
||||||
return sum + (Number(amount) || 0);
|
|
||||||
}, 0);
|
|
||||||
}, [proposalData]);
|
|
||||||
|
|
||||||
// Format date
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
if (!dateString) return '—';
|
|
||||||
try {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-IN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if document can be previewed
|
|
||||||
const canPreviewDocument = (doc: { name: string; url?: string; id?: string }): boolean => {
|
|
||||||
if (!doc.name) return false;
|
|
||||||
const name = doc.name.toLowerCase();
|
|
||||||
return name.endsWith('.pdf') ||
|
|
||||||
name.endsWith('.jpg') ||
|
|
||||||
name.endsWith('.jpeg') ||
|
|
||||||
name.endsWith('.png') ||
|
|
||||||
name.endsWith('.gif') ||
|
|
||||||
name.endsWith('.webp');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle document preview - leverage FilePreview's internal fetching
|
|
||||||
const handlePreviewDocument = (doc: { name: string; url?: string; id?: string; storageUrl?: string; documentId?: string }) => {
|
|
||||||
let fileUrl = doc.url || doc.storageUrl || '';
|
|
||||||
const documentId = doc.id || doc.documentId || '';
|
|
||||||
|
|
||||||
if (!documentId && !fileUrl) {
|
|
||||||
toast.error('Document preview not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle relative URLs for snapshots
|
|
||||||
if (fileUrl && !fileUrl.startsWith('http') && !fileUrl.startsWith('blob:')) {
|
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
||||||
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
|
|
||||||
fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreviewDoc({
|
|
||||||
name: doc.name || 'Document',
|
|
||||||
url: fileUrl || (documentId ? getDocumentPreviewUrl(documentId) : ''),
|
|
||||||
type: (doc.name || '').toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg',
|
|
||||||
id: documentId
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (previewDoc?.url && previewDoc.url.startsWith('blob:')) {
|
|
||||||
window.URL.revokeObjectURL(previewDoc.url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [previewDoc]);
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
if (!comments.trim()) {
|
|
||||||
toast.error('Please provide approval comments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
setActionType('approve');
|
|
||||||
await onApprove(comments);
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to approve proposal:', error);
|
|
||||||
toast.error('Failed to approve proposal. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
setActionType(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReject = async () => {
|
|
||||||
if (!comments.trim()) {
|
|
||||||
toast.error('Please provide rejection reason');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
setActionType('reject');
|
|
||||||
await onReject(comments);
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to reject proposal:', error);
|
|
||||||
toast.error('Failed to reject proposal. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
setActionType(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestRevision = async () => {
|
|
||||||
if (!comments.trim()) {
|
|
||||||
toast.error('Please provide reasons for requesting a revision');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!onRequestRevision) {
|
|
||||||
toast.error('Revision feature is not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
setActionType('revision');
|
|
||||||
await onRequestRevision(comments);
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to request revision:', error);
|
|
||||||
toast.error('Failed to request revision. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
setActionType(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setComments('');
|
|
||||||
setActionType(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!submitting) {
|
|
||||||
handleReset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't return null - show modal even if proposalData is not loaded yet
|
|
||||||
// This allows the modal to open and show a loading/empty state
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl">
|
|
||||||
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
|
|
||||||
Requestor Evaluation & Confirmation
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs lg:text-sm">
|
|
||||||
Step 2: Review dealer proposal and make a decision
|
|
||||||
</DialogDescription>
|
|
||||||
<div className="space-y-1 mt-2 text-xs text-gray-600">
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
|
||||||
<div>
|
|
||||||
<strong>Dealer:</strong> {dealerName}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Activity:</strong> {activityName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-amber-600 font-medium">
|
|
||||||
Decision: <strong>Confirms?</strong> (YES → Continue to Dept Lead / NO → Request is cancelled)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6">
|
|
||||||
|
|
||||||
{/* Previous Proposal Reference Section */}
|
|
||||||
{previousProposalData && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div
|
|
||||||
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
|
|
||||||
onClick={() => setShowPreviousProposal(!showPreviousProposal)}
|
|
||||||
>
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4 text-amber-700" />
|
|
||||||
<span className="text-sm font-semibold text-amber-900">Reference: Previous Proposal Details (last revision)</span>
|
|
||||||
<Badge variant="secondary" className="bg-amber-200 text-amber-800 text-[10px]">
|
|
||||||
₹{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-amber-700">
|
|
||||||
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showPreviousProposal && (
|
|
||||||
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
|
|
||||||
{/* Header Info: Date & Document */}
|
|
||||||
<div className="flex flex-wrap gap-4 text-xs mt-3">
|
|
||||||
{previousProposalData.expectedCompletionDate && (
|
|
||||||
<div className="flex items-center gap-1.5 text-gray-700">
|
|
||||||
<Calendar className="w-3.5 h-3.5 text-gray-500" />
|
|
||||||
<span className="font-medium">Expected Completion:</span>
|
|
||||||
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{previousProposalData.documentUrl && (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
|
|
||||||
<>
|
|
||||||
<Eye className="w-3.5 h-3.5 text-blue-500" />
|
|
||||||
<a
|
|
||||||
href={previousProposalData.documentUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
|
||||||
>
|
|
||||||
View Previous Document
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-3.5 h-3.5 text-blue-500" />
|
|
||||||
<a
|
|
||||||
href={previousProposalData.documentUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Download Previous Document
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cost Breakdown */}
|
|
||||||
{(previousProposalData.costItems || previousProposalData.costBreakup) && (previousProposalData.costItems || previousProposalData.costBreakup).length > 0 && (
|
|
||||||
<div className="w-full pt-2 border-t border-amber-200/50">
|
|
||||||
<p className="text-[10px] font-semibold text-gray-700 mb-2 flex items-center gap-1">
|
|
||||||
<IndianRupee className="w-3 h-3" />
|
|
||||||
Previous Cost Breakdown
|
|
||||||
</p>
|
|
||||||
<div className="border rounded-md overflow-hidden text-[10px]">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 text-gray-600">
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 font-medium">Description</th>
|
|
||||||
<th className="p-2 font-medium text-right">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{(previousProposalData.costItems || previousProposalData.costBreakup).map((item: any, idx: number) => (
|
|
||||||
<tr key={idx} className="bg-white">
|
|
||||||
<td className="p-2 text-gray-800">{item.description}</td>
|
|
||||||
<td className="p-2 text-right text-gray-800 font-medium">
|
|
||||||
₹{Number(item.amount).toLocaleString('en-IN')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
<tr className="bg-gray-50 font-bold border-t">
|
|
||||||
<td className="p-2 text-gray-900">Total</td>
|
|
||||||
<td className="p-2 text-right text-gray-900">
|
|
||||||
₹{Number(previousProposalData.totalEstimatedBudget || previousProposalData.totalBudget || 0).toLocaleString('en-IN')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Additional/Supporting Documents */}
|
|
||||||
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
|
|
||||||
<div className="w-full pt-2 border-t border-amber-200/50">
|
|
||||||
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
|
|
||||||
<FileText className="w-3 h-3" />
|
|
||||||
Supporting Documents
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
|
||||||
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
|
|
||||||
<DocumentCard
|
|
||||||
key={idx}
|
|
||||||
document={{
|
|
||||||
documentId: doc.documentId || doc.id || '',
|
|
||||||
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
|
|
||||||
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
|
|
||||||
uploadedAt: doc.uploadedAt || new Date().toISOString()
|
|
||||||
}}
|
|
||||||
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
|
|
||||||
onDownload={async (id) => {
|
|
||||||
if (id) {
|
|
||||||
await downloadDocument(id);
|
|
||||||
} else {
|
|
||||||
let downloadUrl = doc.storageUrl || doc.documentUrl;
|
|
||||||
if (downloadUrl && !downloadUrl.startsWith('http')) {
|
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
||||||
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
|
|
||||||
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
||||||
}
|
|
||||||
if (downloadUrl) window.open(downloadUrl, '_blank');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments */}
|
|
||||||
{(previousProposalData.comments || previousProposalData.dealerComments) && (
|
|
||||||
<div className="w-full pt-2 border-t border-amber-200/50">
|
|
||||||
<p className="text-[10px] font-semibold text-gray-700 mb-1 flex items-center gap-1">
|
|
||||||
<MessageSquare className="w-3 h-3" />
|
|
||||||
Previous Comments
|
|
||||||
</p>
|
|
||||||
<div className="text-[10px] text-gray-600 bg-white p-2 border border-gray-100 rounded italic">
|
|
||||||
"{previousProposalData.comments || previousProposalData.dealerComments}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
|
|
||||||
{/* Left Column - Documents */}
|
|
||||||
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
|
||||||
{/* Proposal Document Section */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4 text-blue-600" />
|
|
||||||
Proposal Document
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{proposalData?.proposalDocument ? (
|
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
||||||
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
|
|
||||||
{proposalData.proposalDocument.name}
|
|
||||||
</p>
|
|
||||||
{proposalData?.submittedAt && (
|
|
||||||
<p className="text-xs text-gray-500 truncate">
|
|
||||||
Submitted on {formatDate(proposalData.submittedAt)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{proposalData.proposalDocument.id && (
|
|
||||||
<>
|
|
||||||
{canPreviewDocument(proposalData.proposalDocument) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
<Eye className="w-5 h-5 text-blue-600" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
if (proposalData.proposalDocument?.id) {
|
|
||||||
await downloadDocument(proposalData.proposalDocument.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download document:', error);
|
|
||||||
toast.error('Failed to download document');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-500 italic">No proposal document available</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other Supporting Documents */}
|
|
||||||
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4 text-gray-600" />
|
|
||||||
Other Supporting Documents
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{proposalData.otherDocuments.length} file(s)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
|
||||||
{proposalData.otherDocuments.map((doc, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
||||||
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
|
|
||||||
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{doc.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(doc) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(doc)}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
<Eye className="w-5 h-5 text-blue-600" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
if (doc.id) {
|
|
||||||
await downloadDocument(doc.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download document:', error);
|
|
||||||
toast.error('Failed to download document');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Planning & Details */}
|
|
||||||
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
|
||||||
{/* Cost Breakup Section */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<IndianRupee className="w-4 h-4 text-green-600" />
|
|
||||||
Cost Breakup
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
// Ensure costBreakup is an array
|
|
||||||
const costBreakup = proposalData?.costBreakup
|
|
||||||
? (Array.isArray(proposalData.costBreakup)
|
|
||||||
? proposalData.costBreakup
|
|
||||||
: (typeof proposalData.costBreakup === 'string'
|
|
||||||
? JSON.parse(proposalData.costBreakup)
|
|
||||||
: []))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
|
|
||||||
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-xs lg:text-sm font-semibold text-gray-700">
|
|
||||||
<div>Item Description</div>
|
|
||||||
<div className="text-right">Amount</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y">
|
|
||||||
{costBreakup.map((item: any, index: number) => (
|
|
||||||
<div key={item?.id || item?.description || index} className="px-3 lg:px-4 py-2 lg:py-3 grid grid-cols-2 gap-4">
|
|
||||||
<div className="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div>
|
|
||||||
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
|
|
||||||
₹{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<IndianRupee className="w-4 h-4 text-[--re-green]" />
|
|
||||||
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
|
|
||||||
₹{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline Section */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-purple-600" />
|
|
||||||
Expected Completion Date
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
|
|
||||||
<p className="text-sm lg:text-base font-semibold text-gray-900">
|
|
||||||
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comments Section - Side by Side */}
|
|
||||||
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
|
|
||||||
{/* Dealer Comments */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<MessageSquare className="w-4 h-4 text-blue-600" />
|
|
||||||
Dealer Comments
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
|
||||||
<p className="text-xs text-gray-700 whitespace-pre-wrap">
|
|
||||||
{proposalData?.dealerComments || 'No comments provided'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Your Decision & Comments */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
|
|
||||||
value={comments}
|
|
||||||
onChange={(e) => setComments(e.target.value)}
|
|
||||||
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">{comments.length} characters</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Warning for missing comments */}
|
|
||||||
{!comments.trim() && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2">
|
|
||||||
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-xs text-amber-800">
|
|
||||||
Please provide comments before making a decision. Comments are required and will be visible to all participants.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-3 lg:pt-4 flex-shrink-0 border-t bg-gray-50">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
className="border-2 w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<div className="flex flex-col gap-2 w-full sm:w-auto">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleRequestRevision}
|
|
||||||
disabled={!comments.trim() || submitting}
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-amber-100 hover:bg-amber-200 text-amber-900 border border-amber-200 w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{submitting && actionType === 'revision' ? (
|
|
||||||
'Requesting...'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
|
||||||
Request Revised Quotation
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleReject}
|
|
||||||
disabled={!comments.trim() || submitting}
|
|
||||||
variant="destructive"
|
|
||||||
className="bg-red-600 hover:bg-red-700 w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{submitting && actionType === 'reject' ? (
|
|
||||||
'Rejecting...'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XCircle className="w-4 h-4 mr-2" />
|
|
||||||
Reject (Cancel Request)
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleApprove}
|
|
||||||
disabled={!comments.trim() || !isIOBlocked || submitting}
|
|
||||||
className="bg-green-600 hover:bg-green-700 text-white disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
|
|
||||||
title={!isIOBlocked ? 'Please block IO budget before approving' : ''}
|
|
||||||
>
|
|
||||||
{submitting && actionType === 'approve' ? (
|
|
||||||
'Approving...'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
|
||||||
Approve (Continue to Dept Lead)
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* Warning for IO not blocked - shown below Approve button */}
|
|
||||||
{!isIOBlocked && (
|
|
||||||
<p className="text-xs text-red-600 text-center sm:text-left">
|
|
||||||
Please block IO budget in the IO Tab before approving
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
{/* Standardized File Preview */}
|
|
||||||
{previewDoc && (
|
|
||||||
<FilePreview
|
|
||||||
fileName={previewDoc.name}
|
|
||||||
fileType={previewDoc.type || ''}
|
|
||||||
fileUrl={previewDoc.url}
|
|
||||||
fileSize={previewDoc.size}
|
|
||||||
attachmentId={previewDoc.id}
|
|
||||||
onDownload={downloadDocument}
|
|
||||||
open={!!previewDoc}
|
|
||||||
onClose={() => setPreviewDoc(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,307 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
Calendar,
|
|
||||||
Receipt,
|
|
||||||
AlignLeft
|
|
||||||
} from "lucide-react";
|
|
||||||
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
|
|
||||||
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
|
|
||||||
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
|
||||||
|
|
||||||
interface SnapshotDetailsModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
snapshot: any;
|
|
||||||
type: 'PROPOSAL' | 'COMPLETION';
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SnapshotDetailsModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
snapshot,
|
|
||||||
type,
|
|
||||||
title
|
|
||||||
}: SnapshotDetailsModalProps) {
|
|
||||||
// State for preview
|
|
||||||
const [previewDoc, setPreviewDoc] = useState<{
|
|
||||||
fileName: string;
|
|
||||||
fileType: string;
|
|
||||||
documentId: string;
|
|
||||||
fileUrl?: string;
|
|
||||||
fileSize?: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
if (!snapshot) return null;
|
|
||||||
|
|
||||||
const isProposal = type === 'PROPOSAL';
|
|
||||||
|
|
||||||
// Helper to format currency
|
|
||||||
const formatCurrency = (amount: number | string) => {
|
|
||||||
return Number(amount || 0).toLocaleString('en-IN', {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'INR'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to format date
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
if (!dateString) return null;
|
|
||||||
try {
|
|
||||||
return new Date(dateString).toLocaleDateString('en-IN', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to check if file is previewable
|
|
||||||
const canPreview = (fileName: string): boolean => {
|
|
||||||
if (!fileName) return false;
|
|
||||||
const name = fileName.toLowerCase();
|
|
||||||
return name.endsWith('.pdf') ||
|
|
||||||
!!name.match(/\.(jpg|jpeg|png|gif|webp)$/i);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to get file type for DocumentCard
|
|
||||||
const getFileType = (fileName: string) => {
|
|
||||||
const ext = (fileName || '').split('.').pop()?.toLowerCase();
|
|
||||||
if (ext === 'pdf') return 'pdf';
|
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) return 'image';
|
|
||||||
return 'file';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle document preview click
|
|
||||||
const handlePreview = (doc: any) => {
|
|
||||||
const fileName = doc.fileName || doc.originalFileName || (isProposal ? 'Proposal Document' : 'Completion Document');
|
|
||||||
const documentId = doc.documentId || '';
|
|
||||||
const fileType = fileName.toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';
|
|
||||||
|
|
||||||
let fileUrl = '';
|
|
||||||
if (documentId) {
|
|
||||||
fileUrl = getDocumentPreviewUrl(documentId);
|
|
||||||
} else {
|
|
||||||
// Fallback for documents without ID (using direct storageUrl)
|
|
||||||
fileUrl = doc.storageUrl || doc.documentUrl || '';
|
|
||||||
if (fileUrl && !fileUrl.startsWith('http')) {
|
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
||||||
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
|
|
||||||
fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreviewDoc({
|
|
||||||
fileName,
|
|
||||||
fileType,
|
|
||||||
documentId,
|
|
||||||
fileUrl,
|
|
||||||
fileSize: doc.sizeBytes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
|
|
||||||
<DialogHeader className="px-6 py-4 border-b">
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{isProposal ? (
|
|
||||||
<FileText className="w-5 h-5 text-blue-600" />
|
|
||||||
) : (
|
|
||||||
<Receipt className="w-5 h-5 text-green-600" />
|
|
||||||
)}
|
|
||||||
{title || (isProposal ? 'Proposal Snapshot Details' : 'Completion Snapshot Details')}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
View detailed snapshot of the {isProposal ? 'proposal' : 'completion request'} at this version.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 px-6 py-4">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header Stats */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="p-3 bg-gray-50 rounded-lg border border-gray-100">
|
|
||||||
<p className="text-xs text-gray-500 font-medium mb-1">
|
|
||||||
{isProposal ? 'Total Budget' : 'Total Expenses'}
|
|
||||||
</p>
|
|
||||||
<p className={`text-lg font-bold ${isProposal ? 'text-blue-700' : 'text-green-700'}`}>
|
|
||||||
{formatCurrency(snapshot.totalBudget || snapshot.totalExpenses)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isProposal && snapshot.expectedCompletionDate && (
|
|
||||||
<div className="p-3 bg-gray-50 rounded-lg border border-gray-100">
|
|
||||||
<p className="text-xs text-gray-500 font-medium mb-1 flex items-center gap-1">
|
|
||||||
<Calendar className="w-3 h-3" />
|
|
||||||
Expected Completion
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-semibold text-gray-700">
|
|
||||||
{formatDate(snapshot.expectedCompletionDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Document */}
|
|
||||||
{snapshot.documentUrl && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1">
|
|
||||||
Primary Document
|
|
||||||
</h4>
|
|
||||||
<DocumentCard
|
|
||||||
document={{
|
|
||||||
documentId: '',
|
|
||||||
name: isProposal ? 'Proposal Document' : 'Completion Document',
|
|
||||||
fileType: getFileType(snapshot.documentUrl),
|
|
||||||
uploadedAt: new Date().toISOString()
|
|
||||||
}}
|
|
||||||
onPreview={canPreview(snapshot.documentUrl) ? () => handlePreview({
|
|
||||||
fileName: isProposal ? 'Proposal Document' : 'Completion Document',
|
|
||||||
documentUrl: snapshot.documentUrl
|
|
||||||
}) : undefined}
|
|
||||||
onDownload={async () => {
|
|
||||||
// Handle download for document without ID
|
|
||||||
let downloadUrl = snapshot.documentUrl;
|
|
||||||
if (!downloadUrl.startsWith('http')) {
|
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
||||||
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
|
|
||||||
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
||||||
}
|
|
||||||
window.open(downloadUrl, '_blank');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Supporting Documents */}
|
|
||||||
{snapshot.otherDocuments && snapshot.otherDocuments.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1 flex items-center justify-between">
|
|
||||||
<span>Supporting Documents</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">
|
|
||||||
{snapshot.otherDocuments.length} Files
|
|
||||||
</Badge>
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{snapshot.otherDocuments.map((doc: any, idx: number) => (
|
|
||||||
<DocumentCard
|
|
||||||
key={idx}
|
|
||||||
document={{
|
|
||||||
documentId: doc.documentId || '',
|
|
||||||
name: doc.originalFileName || doc.fileName || 'Supporting Document',
|
|
||||||
fileType: getFileType(doc.originalFileName || doc.fileName || ''),
|
|
||||||
uploadedAt: doc.uploadedAt || new Date().toISOString()
|
|
||||||
}}
|
|
||||||
onPreview={canPreview(doc.originalFileName || doc.fileName || '') ? () => handlePreview(doc) : undefined}
|
|
||||||
onDownload={doc.documentId ? downloadDocument : async () => {
|
|
||||||
let downloadUrl = doc.storageUrl || doc.documentUrl;
|
|
||||||
if (downloadUrl && !downloadUrl.startsWith('http')) {
|
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
|
||||||
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
|
|
||||||
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
|
||||||
}
|
|
||||||
if (downloadUrl) window.open(downloadUrl, '_blank');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cost Breakup / Expenses */}
|
|
||||||
{(snapshot.costItems || snapshot.expenses) && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1">
|
|
||||||
{isProposal ? 'Cost Breakdown' : 'Expenses Breakdown'}
|
|
||||||
</h4>
|
|
||||||
<div className="border rounded-md overflow-hidden text-sm">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 text-gray-600 text-xs uppercase">
|
|
||||||
<tr>
|
|
||||||
<th className="p-3 font-medium">Description</th>
|
|
||||||
<th className="p-3 font-medium text-right">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{(snapshot.costItems || snapshot.expenses).length > 0 ? (
|
|
||||||
(snapshot.costItems || snapshot.expenses).map((item: any, idx: number) => (
|
|
||||||
<tr key={idx} className="bg-white hover:bg-gray-50/50">
|
|
||||||
<td className="p-3 text-gray-800">{item.description}</td>
|
|
||||||
<td className="p-3 text-right text-gray-900 font-medium tabular-nums">
|
|
||||||
{formatCurrency(item.amount)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={2} className="p-4 text-center text-gray-500 italic text-xs">
|
|
||||||
No breakdown items available
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
<tr className="bg-gray-50/80 font-semibold text-gray-900 border-t-2 border-gray-100">
|
|
||||||
<td className="p-3">Total</td>
|
|
||||||
<td className="p-3 text-right tabular-nums">
|
|
||||||
{formatCurrency(snapshot.totalBudget || snapshot.totalExpenses)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments */}
|
|
||||||
{snapshot.comments && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1 flex items-center gap-1">
|
|
||||||
<AlignLeft className="w-4 h-4" />
|
|
||||||
Comments
|
|
||||||
</h4>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3 text-sm text-gray-700 italic border border-gray-100">
|
|
||||||
{snapshot.comments}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
|
|
||||||
<Button onClick={onClose}>Close</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* File Preview */}
|
|
||||||
{previewDoc && (
|
|
||||||
<FilePreview
|
|
||||||
fileName={previewDoc.fileName}
|
|
||||||
fileType={previewDoc.fileType}
|
|
||||||
fileUrl={previewDoc.fileUrl}
|
|
||||||
fileSize={previewDoc.fileSize}
|
|
||||||
attachmentId={previewDoc.documentId}
|
|
||||||
onDownload={downloadDocument}
|
|
||||||
open={!!previewDoc}
|
|
||||||
onClose={() => setPreviewDoc(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,18 +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 { AdditionalApproverReviewModal } from './AdditionalApproverReviewModal';
|
|
||||||
export { CreditNoteSAPModal } from './CreditNoteSAPModal';
|
|
||||||
export { DealerCompletionDocumentsModal } from './DealerCompletionDocumentsModal';
|
|
||||||
export { DealerProposalSubmissionModal } from './DealerProposalSubmissionModal';
|
|
||||||
export { DeptLeadIOApprovalModal } from './DeptLeadIOApprovalModal';
|
|
||||||
export { DMSPushModal } from './DMSPushModal';
|
|
||||||
export { EditClaimAmountModal } from './EditClaimAmountModal';
|
|
||||||
export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal';
|
|
||||||
export { InitiatorActionModal } from './InitiatorActionModal';
|
|
||||||
export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';
|
|
||||||
export { SnapshotDetailsModal } from './SnapshotDetailsModal';
|
|
||||||
@ -1,42 +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';
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
export { DealerDashboard } from './pages/Dashboard';
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
export { DealerRequestsFilters } from './components/DealerRequestsFilters';
|
|
||||||
export { DealerClosedRequestsFilters } from './components/DealerClosedRequestsFilters';
|
|
||||||
export { DealerUserAllRequestsFilters } from './components/DealerUserAllRequestsFilters';
|
|
||||||
|
|
||||||
// Re-export types
|
|
||||||
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
|
||||||
@ -1,671 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
|
||||||
import { Shield, Clock, FileText, ChartColumn, ChartPie, Activity, Target, DollarSign, Zap, Package, TrendingUp, TrendingDown, CircleCheckBig, CircleX, CreditCard, TriangleAlert } from 'lucide-react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
||||||
import { getDealerDashboard, type DashboardKPIs as DashboardKPIsType, type CategoryData as CategoryDataType } from '@/services/dealerClaimApi';
|
|
||||||
import { RefreshCw } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
// Use types from dealerClaimApi
|
|
||||||
type DashboardKPIs = DashboardKPIsType;
|
|
||||||
type CategoryData = CategoryDataType;
|
|
||||||
|
|
||||||
interface DashboardProps {
|
|
||||||
onNavigate?: (page: string) => void;
|
|
||||||
onNewRequest?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DealerDashboard({ onNavigate, onNewRequest: _onNewRequest }: DashboardProps) {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [kpis, setKpis] = useState<DashboardKPIs>({
|
|
||||||
totalClaims: 0,
|
|
||||||
totalValue: 0,
|
|
||||||
approved: 0,
|
|
||||||
rejected: 0,
|
|
||||||
pending: 0,
|
|
||||||
credited: 0,
|
|
||||||
pendingCredit: 0,
|
|
||||||
approvedValue: 0,
|
|
||||||
rejectedValue: 0,
|
|
||||||
pendingValue: 0,
|
|
||||||
creditedValue: 0,
|
|
||||||
pendingCreditValue: 0,
|
|
||||||
});
|
|
||||||
const [categoryData, setCategoryData] = useState<CategoryData[]>([]);
|
|
||||||
const [dateRange, _setDateRange] = useState<string>('all');
|
|
||||||
const [startDate, _setStartDate] = useState<string | undefined>();
|
|
||||||
const [endDate, _setEndDate] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const fetchDashboardData = async (isRefresh = false) => {
|
|
||||||
try {
|
|
||||||
if (isRefresh) {
|
|
||||||
setRefreshing(true);
|
|
||||||
} else {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch dealer claims dashboard data
|
|
||||||
const data = await getDealerDashboard(
|
|
||||||
dateRange || 'all',
|
|
||||||
startDate,
|
|
||||||
endDate
|
|
||||||
);
|
|
||||||
|
|
||||||
setKpis(data.kpis || {
|
|
||||||
totalClaims: 0,
|
|
||||||
totalValue: 0,
|
|
||||||
approved: 0,
|
|
||||||
rejected: 0,
|
|
||||||
pending: 0,
|
|
||||||
credited: 0,
|
|
||||||
pendingCredit: 0,
|
|
||||||
approvedValue: 0,
|
|
||||||
rejectedValue: 0,
|
|
||||||
pendingValue: 0,
|
|
||||||
creditedValue: 0,
|
|
||||||
pendingCreditValue: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
setCategoryData(data.categoryData || []);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[DealerDashboard] Error fetching data:', error);
|
|
||||||
toast.error('Failed to load dashboard data. Please try again later.');
|
|
||||||
// Reset to empty state on error
|
|
||||||
setKpis({
|
|
||||||
totalClaims: 0,
|
|
||||||
totalValue: 0,
|
|
||||||
approved: 0,
|
|
||||||
rejected: 0,
|
|
||||||
pending: 0,
|
|
||||||
credited: 0,
|
|
||||||
pendingCredit: 0,
|
|
||||||
approvedValue: 0,
|
|
||||||
rejectedValue: 0,
|
|
||||||
pendingValue: 0,
|
|
||||||
creditedValue: 0,
|
|
||||||
pendingCreditValue: 0,
|
|
||||||
});
|
|
||||||
setCategoryData([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchDashboardData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number, showExactRupees = false) => {
|
|
||||||
// Handle null, undefined, or invalid values
|
|
||||||
if (amount == null || isNaN(amount)) {
|
|
||||||
return '₹0';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to number if it's a string
|
|
||||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : Number(amount);
|
|
||||||
|
|
||||||
// Handle zero or negative values
|
|
||||||
if (numAmount <= 0) {
|
|
||||||
return '₹0';
|
|
||||||
}
|
|
||||||
|
|
||||||
// If showExactRupees is true or amount is less than 10,000, show exact rupees
|
|
||||||
if (showExactRupees || numAmount < 10000) {
|
|
||||||
return `₹${Math.round(numAmount).toLocaleString('en-IN')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numAmount >= 100000) {
|
|
||||||
return `₹${(numAmount / 100000).toFixed(1)}L`;
|
|
||||||
}
|
|
||||||
if (numAmount >= 1000) {
|
|
||||||
return `₹${(numAmount / 1000).toFixed(1)}K`;
|
|
||||||
}
|
|
||||||
// Show exact rupee amount for amounts less than 1000 (e.g., ₹100, ₹200, ₹999)
|
|
||||||
return `₹${Math.round(numAmount).toLocaleString('en-IN')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
|
||||||
return num.toLocaleString('en-IN');
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateApprovalRate = () => {
|
|
||||||
if (kpis.totalClaims === 0) return 0;
|
|
||||||
return ((kpis.approved / kpis.totalClaims) * 100).toFixed(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateCreditRate = () => {
|
|
||||||
if (kpis.approved === 0) return 0;
|
|
||||||
return ((kpis.credited / kpis.approved) * 100).toFixed(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare data for pie chart (Distribution by Activity Type)
|
|
||||||
const distributionData = useMemo(() => {
|
|
||||||
const totalRaised = categoryData.reduce((sum, cat) => sum + cat.raised, 0);
|
|
||||||
if (totalRaised === 0) return [];
|
|
||||||
|
|
||||||
return categoryData.map(cat => ({
|
|
||||||
name: cat.activityType.length > 20 ? cat.activityType.substring(0, 20) + '...' : cat.activityType,
|
|
||||||
value: cat.raised,
|
|
||||||
fullName: cat.activityType,
|
|
||||||
percentage: ((cat.raised / totalRaised) * 100).toFixed(0),
|
|
||||||
}));
|
|
||||||
}, [categoryData]);
|
|
||||||
|
|
||||||
// Prepare data for bar chart (Status by Category)
|
|
||||||
const statusByCategoryData = useMemo(() => {
|
|
||||||
return categoryData.map(cat => ({
|
|
||||||
name: cat.activityType.length > 15 ? cat.activityType.substring(0, 15) + '...' : cat.activityType,
|
|
||||||
fullName: cat.activityType,
|
|
||||||
Raised: cat.raised,
|
|
||||||
Approved: cat.approved,
|
|
||||||
Rejected: cat.rejected,
|
|
||||||
Pending: cat.pending,
|
|
||||||
}));
|
|
||||||
}, [categoryData]);
|
|
||||||
|
|
||||||
// Prepare data for value comparison chart (keep original values, formatCurrency will handle display)
|
|
||||||
const valueComparisonData = useMemo(() => {
|
|
||||||
return categoryData.map(cat => ({
|
|
||||||
name: cat.activityType.length > 15 ? cat.activityType.substring(0, 15) + '...' : cat.activityType,
|
|
||||||
fullName: cat.activityType,
|
|
||||||
Raised: cat.raisedValue, // Keep original value
|
|
||||||
Approved: cat.approvedValue, // Keep original value
|
|
||||||
Credited: cat.creditedValue, // Keep original value
|
|
||||||
}));
|
|
||||||
}, [categoryData]);
|
|
||||||
|
|
||||||
const COLORS = ['#166534', '#15803d', '#16a34a', '#22c55e', '#4ade80', '#86efac', '#bbf7d0'];
|
|
||||||
|
|
||||||
// Find best performing category
|
|
||||||
const bestPerforming = useMemo(() => {
|
|
||||||
if (categoryData.length === 0) return null;
|
|
||||||
return categoryData.reduce((best, cat) =>
|
|
||||||
cat.approvalRate > (best?.approvalRate || 0) ? cat : best
|
|
||||||
);
|
|
||||||
}, [categoryData]);
|
|
||||||
|
|
||||||
// Find highest value category
|
|
||||||
const highestValue = useMemo(() => {
|
|
||||||
if (categoryData.length === 0) return null;
|
|
||||||
return categoryData.reduce((best, cat) =>
|
|
||||||
cat.raisedValue > (best?.raisedValue || 0) ? cat : best
|
|
||||||
);
|
|
||||||
}, [categoryData]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-blue-600" />
|
|
||||||
<p className="text-muted-foreground">Loading dashboard...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show empty state if no data
|
|
||||||
const hasNoData = kpis.totalClaims === 0 && categoryData.length === 0;
|
|
||||||
|
|
||||||
if (hasNoData) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-[1600px] mx-auto p-4">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<Card className="border-0 shadow-xl relative overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" />
|
|
||||||
<CardContent className="relative z-10 p-8 lg:p-12">
|
|
||||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
|
|
||||||
<div className="text-white">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
|
|
||||||
<Shield className="w-8 h-8 text-slate-900" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl text-white font-bold">Claims Analytics Dashboard</h1>
|
|
||||||
<p className="text-xl text-gray-200 mt-1">Comprehensive insights into approval workflows</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-4 mt-8">
|
|
||||||
<Button
|
|
||||||
onClick={() => onNavigate?.('/new-request')}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
|
|
||||||
>
|
|
||||||
<FileText className="w-5 h-5 mr-2" />
|
|
||||||
Create New Claim
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setRefreshing(true);
|
|
||||||
fetchDashboardData(true);
|
|
||||||
}}
|
|
||||||
disabled={refreshing}
|
|
||||||
variant="outline"
|
|
||||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-5 h-5 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
<Card className="shadow-lg">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16 px-4">
|
|
||||||
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
|
||||||
<ChartPie className="w-12 h-12 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mb-2">No Claims Data Available</h2>
|
|
||||||
<p className="text-gray-600 text-center max-w-md mb-6">
|
|
||||||
You don't have any claims data yet. Once you create and submit claim requests, your analytics will appear here.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => onNavigate?.('/new-request')}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
>
|
|
||||||
<FileText className="w-5 h-5 mr-2" />
|
|
||||||
Create Your First Claim
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setRefreshing(true);
|
|
||||||
fetchDashboardData(true);
|
|
||||||
}}
|
|
||||||
disabled={refreshing}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-5 h-5 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
|
||||||
Refresh Data
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-[1600px] mx-auto p-4">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<Card className="border-0 shadow-xl relative overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" />
|
|
||||||
<CardContent className="relative z-10 p-8 lg:p-12">
|
|
||||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
|
|
||||||
<div className="text-white">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
|
|
||||||
<Shield className="w-8 h-8 text-slate-900" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl text-white font-bold">Claims Analytics Dashboard</h1>
|
|
||||||
<p className="text-xl text-gray-200 mt-1">Comprehensive insights into approval workflows</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-4 mt-8">
|
|
||||||
<Button
|
|
||||||
onClick={() => onNavigate?.('/requests?status=pending')}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
|
|
||||||
>
|
|
||||||
<Clock className="w-5 h-5 mr-2" />
|
|
||||||
View Pending Claims
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => onNavigate?.('/requests')}
|
|
||||||
className="bg-emerald-600 hover:bg-emerald-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
|
|
||||||
>
|
|
||||||
<FileText className="w-5 h-5 mr-2" />
|
|
||||||
My Claims
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden lg:flex items-center gap-4">
|
|
||||||
<div className="w-24 h-24 bg-yellow-400/20 rounded-full flex items-center justify-center">
|
|
||||||
<div className="w-16 h-16 bg-yellow-400/30 rounded-full flex items-center justify-center">
|
|
||||||
<ChartColumn className="w-8 h-8 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* KPI Cards */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
|
||||||
<Card className="border-l-4 border-l-blue-500 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm text-muted-foreground">Raised Claims</CardTitle>
|
|
||||||
<div className="p-2 rounded-lg bg-blue-50">
|
|
||||||
<FileText className="h-4 w-4 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl text-gray-900">{formatNumber(kpis.totalClaims)}</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.totalValue, true)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-green-500 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm text-muted-foreground">Approved</CardTitle>
|
|
||||||
<div className="p-2 rounded-lg bg-green-50">
|
|
||||||
<CircleCheckBig className="h-4 w-4 text-green-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl text-gray-900">{formatNumber(kpis.approved)}</div>
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
|
||||||
<TrendingUp className="h-3 w-3 text-green-600" />
|
|
||||||
<p className="text-xs text-green-600">{calculateApprovalRate()}% approval rate</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-red-500 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm text-muted-foreground">Rejected</CardTitle>
|
|
||||||
<div className="p-2 rounded-lg bg-red-50">
|
|
||||||
<CircleX className="h-4 w-4 text-red-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl text-gray-900">{formatNumber(kpis.rejected)}</div>
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
|
||||||
<TrendingDown className="h-3 w-3 text-red-600" />
|
|
||||||
<p className="text-xs text-red-600">
|
|
||||||
{kpis.totalClaims > 0 ? ((kpis.rejected / kpis.totalClaims) * 100).toFixed(1) : 0}% rejection rate
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-orange-500 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm text-muted-foreground">Pending</CardTitle>
|
|
||||||
<div className="p-2 rounded-lg bg-orange-50">
|
|
||||||
<Clock className="h-4 w-4 text-orange-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl text-gray-900">{formatNumber(kpis.pending)}</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.pendingValue)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-emerald-500 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm text-muted-foreground">Credited</CardTitle>
|
|
||||||
<div className="p-2 rounded-lg bg-emerald-50">
|
|
||||||
<CreditCard className="h-4 w-4 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl text-gray-900">{formatNumber(kpis.credited)}</div>
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
|
||||||
<TrendingUp className="h-3 w-3 text-emerald-600" />
|
|
||||||
<p className="text-xs text-emerald-600">{calculateCreditRate()}% credit rate</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-amber-500 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm text-muted-foreground">Pending Credit</CardTitle>
|
|
||||||
<div className="p-2 rounded-lg bg-amber-50">
|
|
||||||
<TriangleAlert className="h-4 w-4 text-amber-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl text-gray-900">{formatNumber(kpis.pendingCredit)}</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.pendingCreditValue)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts Section */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Distribution by Activity Type */}
|
|
||||||
<Card className="shadow-lg">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 bg-purple-100 rounded-lg">
|
|
||||||
<ChartPie className="h-5 w-5 text-purple-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle>Claims Distribution by Activity Type</CardTitle>
|
|
||||||
<CardDescription>Total claims raised across activity types</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={distributionData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={({ name, percentage }) => `${name}: ${percentage}%`}
|
|
||||||
outerRadius={80}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{distributionData.map((_entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div className="grid grid-cols-3 gap-2 mt-4">
|
|
||||||
{distributionData.slice(0, 3).map((item, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
|
|
||||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-600">{item.name}</p>
|
|
||||||
<p className="text-sm text-gray-900">{formatNumber(item.value)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Status by Category */}
|
|
||||||
<Card className="shadow-lg">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 bg-blue-100 rounded-lg">
|
|
||||||
<ChartColumn className="h-5 w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle>Claims Status by Activity Type</CardTitle>
|
|
||||||
<CardDescription>Count comparison across workflow stages</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<BarChart data={statusByCategoryData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="name" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="Raised" fill="#3b82f6" />
|
|
||||||
<Bar dataKey="Approved" fill="#22c55e" />
|
|
||||||
<Bar dataKey="Rejected" fill="#ef4444" />
|
|
||||||
<Bar dataKey="Pending" fill="#f59e0b" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detailed Category Breakdown */}
|
|
||||||
<Card className="shadow-lg">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 bg-emerald-100 rounded-lg">
|
|
||||||
<Activity className="h-5 w-5 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle>Detailed Activity Type Breakdown</CardTitle>
|
|
||||||
<CardDescription>In-depth analysis of claims by type and status</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg mb-4 text-gray-900">Activity Type Value Comparison</h3>
|
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
|
||||||
<BarChart data={valueComparisonData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="name" />
|
|
||||||
<YAxis tickFormatter={(value) => formatCurrency(value)} />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
|
||||||
labelFormatter={(label) => label}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="Raised" fill="#3b82f6" />
|
|
||||||
<Bar dataKey="Approved" fill="#22c55e" />
|
|
||||||
<Bar dataKey="Credited" fill="#10b981" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
|
||||||
{categoryData.slice(0, 3).map((cat, index) => (
|
|
||||||
<Card key={index} className="shadow-md hover:shadow-lg transition-shadow">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-base">{cat.activityType}</CardTitle>
|
|
||||||
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
|
||||||
{cat.approvalRate.toFixed(1)}% approved
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Raised:</span>
|
|
||||||
<span className="text-gray-900">{formatNumber(cat.raised)} ({formatCurrency(cat.raisedValue)})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Approved:</span>
|
|
||||||
<span className="text-green-600">{formatNumber(cat.approved)} ({formatCurrency(cat.approvedValue)})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Rejected:</span>
|
|
||||||
<span className="text-red-600">{formatNumber(cat.rejected)} ({formatCurrency(cat.rejectedValue)})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Pending:</span>
|
|
||||||
<span className="text-orange-600">{formatNumber(cat.pending)} ({formatCurrency(cat.pendingValue)})</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-px bg-gray-200 my-2" />
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Credited:</span>
|
|
||||||
<span className="text-emerald-600">{formatNumber(cat.credited)} ({formatCurrency(cat.creditedValue)})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Pending Credit:</span>
|
|
||||||
<span className="text-amber-600">{formatNumber(cat.pendingCredit)} ({formatCurrency(cat.pendingCreditValue)})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pt-2">
|
|
||||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
|
||||||
<span>Credit Rate</span>
|
|
||||||
<span>{cat.creditRate.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={cat.creditRate} className="h-2" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Performance Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<Card className="border-t-4 border-t-green-500 shadow-lg hover:shadow-xl transition-shadow">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="p-3 bg-green-100 rounded-lg">
|
|
||||||
<Target className="h-6 w-6 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm text-gray-600 mb-1">Best Performing</h3>
|
|
||||||
<p className="text-xl text-gray-900 mb-1">{bestPerforming?.activityType || 'N/A'}</p>
|
|
||||||
<p className="text-sm text-green-600">{bestPerforming?.approvalRate.toFixed(2) || 0}% approval rate</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-t-4 border-t-blue-500 shadow-lg hover:shadow-xl transition-shadow">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="p-3 bg-blue-100 rounded-lg">
|
|
||||||
<DollarSign className="h-6 w-6 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<Activity className="h-5 w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm text-gray-600 mb-1">Top Activity Type</h3>
|
|
||||||
<p className="text-xl text-gray-900 mb-1">{highestValue?.activityType || 'N/A'}</p>
|
|
||||||
<p className="text-sm text-blue-600">{highestValue ? formatCurrency(highestValue.raisedValue, true) : '₹0'} raised</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-t-4 border-t-emerald-500 shadow-lg hover:shadow-xl transition-shadow">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="p-3 bg-emerald-100 rounded-lg">
|
|
||||||
<Zap className="h-6 w-6 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<CircleCheckBig className="h-5 w-5 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm text-gray-600 mb-1">Overall Credit Rate</h3>
|
|
||||||
<p className="text-xl text-gray-900 mb-1">{calculateCreditRate()}%</p>
|
|
||||||
<p className="text-sm text-emerald-600">{formatNumber(kpis.credited)} claims credited</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-t-4 border-t-amber-500 shadow-lg hover:shadow-xl transition-shadow">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="p-3 bg-amber-100 rounded-lg">
|
|
||||||
<Package className="h-6 w-6 text-amber-600" />
|
|
||||||
</div>
|
|
||||||
<TriangleAlert className="h-5 w-5 text-amber-600" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm text-gray-600 mb-1">Pending Action</h3>
|
|
||||||
<p className="text-xl text-gray-900 mb-1">{formatNumber(kpis.pendingCredit)}</p>
|
|
||||||
<p className="text-sm text-amber-600">{formatCurrency(kpis.pendingCreditValue)} awaiting credit</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,801 +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';
|
|
||||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
|
||||||
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
|
||||||
import { getSocket, joinUserRoom } from '@/utils/socket';
|
|
||||||
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
|
|
||||||
|
|
||||||
// Dealer Claim Components (import from index to get properly aliased exports)
|
|
||||||
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
|
|
||||||
|
|
||||||
// Shared Components
|
|
||||||
import { SharedComponents } from '@/shared/components';
|
|
||||||
const { DocumentsTab, ActivityTab, WorkNotesTab, SummaryTab, RequestDetailHeader, QuickActionsSidebar, RequestDetailModals } = SharedComponents;
|
|
||||||
|
|
||||||
// Other components
|
|
||||||
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
|
||||||
import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
|
||||||
import { PauseModal } from '@/components/workflow/PauseModal';
|
|
||||||
import { ResumeModal } from '@/components/workflow/ResumeModal';
|
|
||||||
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error Boundary Component
|
|
||||||
*/
|
|
||||||
class RequestDetailErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: Error | null }> {
|
|
||||||
constructor(props: { children: ReactNode }) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error) {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error('Dealer Claim RequestDetail Error:', error, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
|
|
||||||
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
|
|
||||||
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
|
||||||
<h2 className="text-2xl font-bold mb-2">Error Loading Request</h2>
|
|
||||||
<p className="text-gray-600 mb-4">{this.state.error?.message || 'An unexpected error occurred'}</p>
|
|
||||||
<Button onClick={() => window.location.reload()} className="mr-2">
|
|
||||||
Reload Page
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => window.history.back()}>
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dealer Claim RequestDetailInner Component
|
|
||||||
*/
|
|
||||||
function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests = [] }: RequestDetailProps) {
|
|
||||||
const params = useParams<{ requestId: string }>();
|
|
||||||
const requestIdentifier = params.requestId || propRequestId || '';
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const initialTab = urlParams.get('tab') || 'overview';
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(initialTab);
|
|
||||||
const [showShareSummaryModal, setShowShareSummaryModal] = useState(false);
|
|
||||||
const [summaryId, setSummaryId] = useState<string | null>(null);
|
|
||||||
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
|
|
||||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
|
||||||
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
|
|
||||||
const [showPauseModal, setShowPauseModal] = useState(false);
|
|
||||||
const [showResumeModal, setShowResumeModal] = useState(false);
|
|
||||||
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
|
||||||
const [systemPolicy, setSystemPolicy] = useState<{
|
|
||||||
maxApprovalLevels: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
allowSpectators: boolean;
|
|
||||||
maxSpectators: number;
|
|
||||||
}>({
|
|
||||||
maxApprovalLevels: 10,
|
|
||||||
maxParticipants: 50,
|
|
||||||
allowSpectators: true,
|
|
||||||
maxSpectators: 20
|
|
||||||
});
|
|
||||||
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
|
||||||
open: boolean;
|
|
||||||
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
|
||||||
}>({
|
|
||||||
open: false,
|
|
||||||
violations: []
|
|
||||||
});
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
// Custom hooks
|
|
||||||
const {
|
|
||||||
request,
|
|
||||||
apiRequest,
|
|
||||||
loading: requestLoading,
|
|
||||||
refreshing,
|
|
||||||
refreshDetails,
|
|
||||||
currentApprovalLevel,
|
|
||||||
isSpectator,
|
|
||||||
isInitiator,
|
|
||||||
existingParticipants,
|
|
||||||
accessDenied,
|
|
||||||
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
|
|
||||||
|
|
||||||
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
|
|
||||||
const currentUserId = (user as any)?.userId || '';
|
|
||||||
|
|
||||||
// IO tab visibility for dealer claims
|
|
||||||
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
|
|
||||||
const showIOTab = isInitiator;
|
|
||||||
|
|
||||||
const {
|
|
||||||
mergedMessages,
|
|
||||||
unreadWorkNotes,
|
|
||||||
workNoteAttachments,
|
|
||||||
setWorkNoteAttachments,
|
|
||||||
} = useRequestSocket(requestIdentifier, apiRequest, activeTab, user);
|
|
||||||
|
|
||||||
const {
|
|
||||||
uploadingDocument,
|
|
||||||
triggerFileInput,
|
|
||||||
previewDocument,
|
|
||||||
setPreviewDocument,
|
|
||||||
documentPolicy,
|
|
||||||
documentError,
|
|
||||||
setDocumentError,
|
|
||||||
} = useDocumentUpload(apiRequest, refreshDetails);
|
|
||||||
|
|
||||||
// State to temporarily store approval level for modal (used for additional approvers)
|
|
||||||
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
|
|
||||||
|
|
||||||
// Use temporary level if set, otherwise use currentApprovalLevel
|
|
||||||
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
|
|
||||||
|
|
||||||
const {
|
|
||||||
showApproveModal,
|
|
||||||
setShowApproveModal,
|
|
||||||
showRejectModal,
|
|
||||||
setShowRejectModal,
|
|
||||||
showAddApproverModal,
|
|
||||||
setShowAddApproverModal,
|
|
||||||
showAddSpectatorModal,
|
|
||||||
setShowAddSpectatorModal,
|
|
||||||
showSkipApproverModal,
|
|
||||||
setShowSkipApproverModal,
|
|
||||||
showActionStatusModal,
|
|
||||||
setShowActionStatusModal,
|
|
||||||
skipApproverData,
|
|
||||||
setSkipApproverData,
|
|
||||||
actionStatus,
|
|
||||||
setActionStatus,
|
|
||||||
handleApproveConfirm: originalHandleApproveConfirm,
|
|
||||||
handleRejectConfirm: originalHandleRejectConfirm,
|
|
||||||
handleAddApprover,
|
|
||||||
handleSkipApprover,
|
|
||||||
handleAddSpectator,
|
|
||||||
} = useModalManager(requestIdentifier, effectiveApprovalLevel, refreshDetails);
|
|
||||||
|
|
||||||
// Wrapper handlers that clear temporary level after action
|
|
||||||
const handleApproveConfirm = async (description: string) => {
|
|
||||||
await originalHandleApproveConfirm(description);
|
|
||||||
setTemporaryApprovalLevel(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRejectConfirm = async (description: string) => {
|
|
||||||
await originalHandleRejectConfirm(description);
|
|
||||||
setTemporaryApprovalLevel(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Closure functionality - only for initiator when request is approved/rejected
|
|
||||||
// Check both lowercase and uppercase status values
|
|
||||||
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
|
|
||||||
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
|
|
||||||
|
|
||||||
// Closure check completed
|
|
||||||
const {
|
|
||||||
conclusionRemark,
|
|
||||||
setConclusionRemark,
|
|
||||||
conclusionLoading,
|
|
||||||
conclusionSubmitting,
|
|
||||||
aiGenerated,
|
|
||||||
handleGenerateConclusion,
|
|
||||||
handleFinalizeConclusion,
|
|
||||||
generationAttempts,
|
|
||||||
generationFailed,
|
|
||||||
maxAttemptsReached,
|
|
||||||
} = useConclusionRemark(
|
|
||||||
request,
|
|
||||||
requestIdentifier,
|
|
||||||
isInitiator,
|
|
||||||
refreshDetails,
|
|
||||||
onBack,
|
|
||||||
setActionStatus,
|
|
||||||
setShowActionStatusModal
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load system policy on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadSystemPolicy = async () => {
|
|
||||||
try {
|
|
||||||
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
|
||||||
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
|
||||||
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
|
||||||
const configMap: Record<string, string> = {};
|
|
||||||
allConfigs.forEach((c: AdminConfiguration) => {
|
|
||||||
configMap[c.configKey] = c.configValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
setSystemPolicy({
|
|
||||||
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
|
|
||||||
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
|
|
||||||
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
|
|
||||||
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load system policy:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSystemPolicy();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-switch tab when URL query parameter changes
|
|
||||||
useEffect(() => {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const tabParam = urlParams.get('tab');
|
|
||||||
if (tabParam) {
|
|
||||||
setActiveTab(tabParam);
|
|
||||||
}
|
|
||||||
}, [requestIdentifier]);
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
refreshDetails();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pause handlers
|
|
||||||
const handlePause = () => {
|
|
||||||
setShowPauseModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResume = () => {
|
|
||||||
setShowResumeModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResumeSuccess = async () => {
|
|
||||||
await refreshDetails();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRetrigger = () => {
|
|
||||||
setShowRetriggerModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePauseSuccess = async () => {
|
|
||||||
await refreshDetails();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRetriggerSuccess = async () => {
|
|
||||||
await refreshDetails();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShareSummary = async () => {
|
|
||||||
if (!apiRequest?.requestId) {
|
|
||||||
toast.error('Request ID not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!summaryId) {
|
|
||||||
toast.error('Summary not available. Please ensure the request is closed and the summary has been generated.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowShareSummaryModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isClosed = request?.status === 'closed';
|
|
||||||
|
|
||||||
// Fetch summary details if request is closed
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSummaryDetails = async () => {
|
|
||||||
if (!isClosed || !apiRequest?.requestId) {
|
|
||||||
setSummaryDetails(null);
|
|
||||||
setSummaryId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoadingSummary(true);
|
|
||||||
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
|
||||||
|
|
||||||
if (summary?.summaryId) {
|
|
||||||
setSummaryId(summary.summaryId);
|
|
||||||
try {
|
|
||||||
const details = await getSummaryDetails(summary.summaryId);
|
|
||||||
setSummaryDetails(details);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to fetch summary details:', error);
|
|
||||||
setSummaryDetails(null);
|
|
||||||
setSummaryId(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSummaryDetails(null);
|
|
||||||
setSummaryId(null);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setSummaryDetails(null);
|
|
||||||
setSummaryId(null);
|
|
||||||
} finally {
|
|
||||||
setLoadingSummary(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSummaryDetails();
|
|
||||||
}, [isClosed, apiRequest?.requestId]);
|
|
||||||
|
|
||||||
// Listen for credit note notifications and trigger silent refresh
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUserId || !apiRequest?.requestId) return;
|
|
||||||
|
|
||||||
const socket = getSocket();
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
joinUserRoom(socket, currentUserId);
|
|
||||||
|
|
||||||
const handleNewNotification = (data: { notification: any }) => {
|
|
||||||
const notif = data?.notification;
|
|
||||||
if (!notif) return;
|
|
||||||
|
|
||||||
const notifRequestId = notif.requestId || notif.request_id;
|
|
||||||
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
|
|
||||||
if (notifRequestId !== apiRequest.requestId &&
|
|
||||||
notifRequestNumber !== requestIdentifier &&
|
|
||||||
notifRequestNumber !== apiRequest.requestNumber) return;
|
|
||||||
|
|
||||||
// Check for credit note metadata
|
|
||||||
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
|
|
||||||
refreshDetails();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('notification:new', handleNewNotification);
|
|
||||||
return () => {
|
|
||||||
socket.off('notification:new', handleNewNotification);
|
|
||||||
};
|
|
||||||
}, [currentUserId, apiRequest?.requestId, requestIdentifier, refreshDetails]);
|
|
||||||
|
|
||||||
// Get current levels for WorkNotesTab
|
|
||||||
const currentLevels = (request?.approvalFlow || [])
|
|
||||||
.filter((flow: any) => flow && typeof flow.step === 'number')
|
|
||||||
.map((flow: any) => ({
|
|
||||||
levelNumber: flow.step || 0,
|
|
||||||
approverName: flow.approver || 'Unknown',
|
|
||||||
status: flow.status || 'pending',
|
|
||||||
tatHours: flow.tatHours || 24,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (requestLoading && !request && !apiRequest) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state">
|
|
||||||
<div className="text-center">
|
|
||||||
<RefreshCw className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
|
|
||||||
<p className="text-gray-600">Loading dealer claim request details...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access Denied state
|
|
||||||
if (accessDenied?.denied) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="access-denied-state">
|
|
||||||
<div className="max-w-lg w-full bg-white rounded-2xl shadow-xl p-8 text-center">
|
|
||||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<ShieldX className="w-10 h-10 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">Access Denied</h2>
|
|
||||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
|
||||||
{accessDenied.message}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 justify-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onBack || (() => window.history.back())}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Go to Dashboard
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not Found state
|
|
||||||
if (!request) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="not-found-state">
|
|
||||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
|
|
||||||
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<FileText className="w-10 h-10 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">Dealer Claim Request Not Found</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
The dealer claim request you're looking for doesn't exist or may have been deleted.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 justify-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onBack || (() => window.history.back())}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Go to Dashboard
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="min-h-screen bg-gray-50" data-testid="dealer-claim-request-detail-page">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
{/* Header Section */}
|
|
||||||
<RequestDetailHeader
|
|
||||||
request={request}
|
|
||||||
refreshing={refreshing}
|
|
||||||
onBack={onBack || (() => window.history.back())}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
onShareSummary={handleShareSummary}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
// Dealer-claim module: Business logic for preparing SLA data
|
|
||||||
slaData={request?.summary?.sla || request?.sla || null}
|
|
||||||
isPaused={request?.pauseInfo?.isPaused || false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="dealer-claim-request-detail-tabs">
|
|
||||||
<div className="mb-4 sm:mb-6">
|
|
||||||
<TabsList className="grid grid-cols-3 sm:grid-cols-6 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
|
|
||||||
<TabsTrigger
|
|
||||||
value="overview"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-overview"
|
|
||||||
>
|
|
||||||
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Overview</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
{isClosed && summaryDetails && (
|
|
||||||
<TabsTrigger
|
|
||||||
value="summary"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-summary"
|
|
||||||
>
|
|
||||||
<FileCheck className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Summary</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
<TabsTrigger
|
|
||||||
value="workflow"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-workflow"
|
|
||||||
>
|
|
||||||
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Workflow</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
{showIOTab && (
|
|
||||||
<TabsTrigger
|
|
||||||
value="io"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-io"
|
|
||||||
>
|
|
||||||
<DollarSign className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">IO</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
<TabsTrigger
|
|
||||||
value="documents"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
|
||||||
data-testid="tab-documents"
|
|
||||||
>
|
|
||||||
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Docs</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="activity"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 col-span-1 sm:col-span-1"
|
|
||||||
data-testid="tab-activity"
|
|
||||||
>
|
|
||||||
<Activity className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Activity</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="worknotes"
|
|
||||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 relative col-span-2 sm:col-span-1"
|
|
||||||
data-testid="tab-worknotes"
|
|
||||||
>
|
|
||||||
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">Work Notes</span>
|
|
||||||
{unreadWorkNotes > 0 && (
|
|
||||||
<Badge
|
|
||||||
className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-[10px] flex items-center justify-center p-0"
|
|
||||||
data-testid="worknotes-unread-badge"
|
|
||||||
>
|
|
||||||
{unreadWorkNotes > 9 ? '9+' : unreadWorkNotes}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Layout */}
|
|
||||||
<div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
|
|
||||||
{/* Left Column: Tab content */}
|
|
||||||
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
|
|
||||||
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
|
|
||||||
<DealerClaimOverviewTab
|
|
||||||
request={request}
|
|
||||||
apiRequest={apiRequest}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
needsClosure={needsClosure}
|
|
||||||
conclusionRemark={conclusionRemark}
|
|
||||||
setConclusionRemark={setConclusionRemark}
|
|
||||||
conclusionLoading={conclusionLoading}
|
|
||||||
conclusionSubmitting={conclusionSubmitting}
|
|
||||||
aiGenerated={aiGenerated}
|
|
||||||
handleGenerateConclusion={handleGenerateConclusion}
|
|
||||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
|
||||||
generationAttempts={generationAttempts}
|
|
||||||
generationFailed={generationFailed}
|
|
||||||
maxAttemptsReached={maxAttemptsReached}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{isClosed && (
|
|
||||||
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
|
|
||||||
<SummaryTab
|
|
||||||
summary={summaryDetails}
|
|
||||||
loading={loadingSummary}
|
|
||||||
onShare={handleShareSummary}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TabsContent value="workflow" className="mt-0">
|
|
||||||
<DealerClaimWorkflowTab
|
|
||||||
request={request}
|
|
||||||
user={user}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
onSkipApprover={(data) => {
|
|
||||||
if (!data.levelId) {
|
|
||||||
alert('Level ID not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSkipApproverData(data);
|
|
||||||
setShowSkipApproverModal(true);
|
|
||||||
}}
|
|
||||||
onRefresh={refreshDetails}
|
|
||||||
documentPolicy={documentPolicy}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{showIOTab && (
|
|
||||||
<TabsContent value="io" className="mt-0">
|
|
||||||
<IOTab
|
|
||||||
request={request}
|
|
||||||
apiRequest={apiRequest}
|
|
||||||
onRefresh={refreshDetails}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TabsContent value="documents" className="mt-0">
|
|
||||||
<DocumentsTab
|
|
||||||
request={request}
|
|
||||||
workNoteAttachments={workNoteAttachments}
|
|
||||||
uploadingDocument={uploadingDocument}
|
|
||||||
documentPolicy={documentPolicy}
|
|
||||||
triggerFileInput={triggerFileInput}
|
|
||||||
setPreviewDocument={setPreviewDocument}
|
|
||||||
downloadDocument={downloadDocument}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="activity" className="mt-0">
|
|
||||||
<ActivityTab request={request} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="worknotes" className="mt-0" forceMount={true} hidden={activeTab !== 'worknotes'}>
|
|
||||||
<WorkNotesTab
|
|
||||||
requestId={requestIdentifier}
|
|
||||||
requestTitle={request.title}
|
|
||||||
mergedMessages={mergedMessages}
|
|
||||||
setWorkNoteAttachments={setWorkNoteAttachments}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
isSpectator={isSpectator}
|
|
||||||
currentLevels={currentLevels}
|
|
||||||
onAddApprover={handleAddApprover}
|
|
||||||
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
|
||||||
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Quick Actions Sidebar */}
|
|
||||||
{activeTab !== 'worknotes' && (
|
|
||||||
<QuickActionsSidebar
|
|
||||||
request={request}
|
|
||||||
isInitiator={isInitiator}
|
|
||||||
isSpectator={isSpectator}
|
|
||||||
currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel}
|
|
||||||
onAddApprover={() => setShowAddApproverModal(true)}
|
|
||||||
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
|
||||||
onApprove={() => setShowApproveModal(true)}
|
|
||||||
onReject={() => setShowRejectModal(true)}
|
|
||||||
onPause={handlePause}
|
|
||||||
onResume={handleResume}
|
|
||||||
onRetrigger={handleRetrigger}
|
|
||||||
summaryId={summaryId}
|
|
||||||
refreshTrigger={sharedRecipientsRefreshTrigger}
|
|
||||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
apiRequest={apiRequest}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share Summary Modal */}
|
|
||||||
{showShareSummaryModal && summaryId && (
|
|
||||||
<ShareSummaryModal
|
|
||||||
isOpen={showShareSummaryModal}
|
|
||||||
onClose={() => setShowShareSummaryModal(false)}
|
|
||||||
summaryId={summaryId}
|
|
||||||
requestTitle={request?.title || 'N/A'}
|
|
||||||
onSuccess={() => {
|
|
||||||
refreshDetails();
|
|
||||||
setSharedRecipientsRefreshTrigger(prev => prev + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pause Modals */}
|
|
||||||
{showPauseModal && apiRequest?.requestId && (
|
|
||||||
<PauseModal
|
|
||||||
isOpen={showPauseModal}
|
|
||||||
onClose={() => setShowPauseModal(false)}
|
|
||||||
requestId={apiRequest.requestId}
|
|
||||||
levelId={currentApprovalLevel?.levelId || null}
|
|
||||||
onSuccess={handlePauseSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showResumeModal && apiRequest?.requestId && (
|
|
||||||
<ResumeModal
|
|
||||||
isOpen={showResumeModal}
|
|
||||||
onClose={() => setShowResumeModal(false)}
|
|
||||||
requestId={apiRequest.requestId}
|
|
||||||
onSuccess={handleResumeSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showRetriggerModal && apiRequest?.requestId && (
|
|
||||||
<RetriggerPauseModal
|
|
||||||
isOpen={showRetriggerModal}
|
|
||||||
onClose={() => setShowRetriggerModal(false)}
|
|
||||||
requestId={apiRequest.requestId}
|
|
||||||
approverName={request?.pauseInfo?.pausedBy?.name}
|
|
||||||
onSuccess={handleRetriggerSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<RequestDetailModals
|
|
||||||
showApproveModal={showApproveModal}
|
|
||||||
showRejectModal={showRejectModal}
|
|
||||||
showAddApproverModal={showAddApproverModal}
|
|
||||||
showAddSpectatorModal={showAddSpectatorModal}
|
|
||||||
showSkipApproverModal={showSkipApproverModal}
|
|
||||||
showActionStatusModal={showActionStatusModal}
|
|
||||||
previewDocument={previewDocument}
|
|
||||||
documentError={documentError}
|
|
||||||
request={request}
|
|
||||||
skipApproverData={skipApproverData}
|
|
||||||
actionStatus={actionStatus}
|
|
||||||
existingParticipants={existingParticipants}
|
|
||||||
currentLevels={currentLevels}
|
|
||||||
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
|
||||||
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
|
||||||
setShowApproveModal={setShowApproveModal}
|
|
||||||
setShowRejectModal={setShowRejectModal}
|
|
||||||
setShowAddApproverModal={setShowAddApproverModal}
|
|
||||||
setShowAddSpectatorModal={setShowAddSpectatorModal}
|
|
||||||
setShowSkipApproverModal={setShowSkipApproverModal}
|
|
||||||
setShowActionStatusModal={setShowActionStatusModal}
|
|
||||||
setPreviewDocument={setPreviewDocument}
|
|
||||||
setDocumentError={setDocumentError}
|
|
||||||
setSkipApproverData={setSkipApproverData}
|
|
||||||
setActionStatus={setActionStatus}
|
|
||||||
handleApproveConfirm={handleApproveConfirm}
|
|
||||||
handleRejectConfirm={handleRejectConfirm}
|
|
||||||
handleAddApprover={handleAddApprover}
|
|
||||||
handleAddSpectator={handleAddSpectator}
|
|
||||||
handleSkipApprover={handleSkipApprover}
|
|
||||||
downloadDocument={downloadDocument}
|
|
||||||
documentPolicy={documentPolicy}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Policy Violation Modal */}
|
|
||||||
<PolicyViolationModal
|
|
||||||
open={policyViolationModal.open}
|
|
||||||
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
|
|
||||||
violations={policyViolationModal.violations}
|
|
||||||
policyDetails={{
|
|
||||||
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
|
||||||
maxParticipants: systemPolicy.maxParticipants,
|
|
||||||
allowSpectators: systemPolicy.allowSpectators,
|
|
||||||
maxSpectators: systemPolicy.maxSpectators,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dealer Claim RequestDetail Component (Exported)
|
|
||||||
*/
|
|
||||||
export function DealerClaimRequestDetail(props: RequestDetailProps) {
|
|
||||||
return (
|
|
||||||
<RequestDetailErrorBoundary>
|
|
||||||
<DealerClaimRequestDetailInner {...props} />
|
|
||||||
</RequestDetailErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
167
src/flows.ts
167
src/flows.ts
@ -1,167 +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 { UserFilterType } from '@/utils/userFilterUtils';
|
|
||||||
|
|
||||||
// Import flow modules from src/ level
|
|
||||||
import * as CustomFlow from './custom';
|
|
||||||
import * as DealerClaimFlow from './dealer-claim';
|
|
||||||
import * as SharedComponents from './shared/components';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flow registry mapping
|
|
||||||
* Maps RequestFlowType to their respective flow modules
|
|
||||||
*/
|
|
||||||
export const FlowRegistry = {
|
|
||||||
CUSTOM: CustomFlow,
|
|
||||||
DEALER_CLAIM: DealerClaimFlow,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get flow module for a given flow type
|
|
||||||
*/
|
|
||||||
export function getFlowModule(flowType: RequestFlowType) {
|
|
||||||
return FlowRegistry[flowType];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get overview tab component for a flow type
|
|
||||||
*/
|
|
||||||
export function getOverviewTab(flowType: RequestFlowType) {
|
|
||||||
switch (flowType) {
|
|
||||||
case 'DEALER_CLAIM':
|
|
||||||
return DealerClaimFlow.DealerClaimOverviewTab;
|
|
||||||
case 'CUSTOM':
|
|
||||||
default:
|
|
||||||
return CustomFlow.CustomOverviewTab;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get workflow tab component for a flow type
|
|
||||||
*/
|
|
||||||
export function getWorkflowTab(flowType: RequestFlowType) {
|
|
||||||
switch (flowType) {
|
|
||||||
case 'DEALER_CLAIM':
|
|
||||||
return DealerClaimFlow.DealerClaimWorkflowTab;
|
|
||||||
case 'CUSTOM':
|
|
||||||
default:
|
|
||||||
return CustomFlow.CustomWorkflowTab;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get create request component for a flow type
|
|
||||||
*/
|
|
||||||
export function getCreateRequestComponent(flowType: RequestFlowType) {
|
|
||||||
switch (flowType) {
|
|
||||||
case 'DEALER_CLAIM':
|
|
||||||
return DealerClaimFlow.ClaimManagementWizard;
|
|
||||||
case 'CUSTOM':
|
|
||||||
default:
|
|
||||||
return CustomFlow.CustomCreateRequest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get RequestDetail screen component for a flow type
|
|
||||||
* Each flow has its own complete RequestDetail screen
|
|
||||||
*/
|
|
||||||
export function getRequestDetailScreen(flowType: RequestFlowType) {
|
|
||||||
switch (flowType) {
|
|
||||||
case 'DEALER_CLAIM':
|
|
||||||
return DealerClaimFlow.DealerClaimRequestDetail;
|
|
||||||
case 'CUSTOM':
|
|
||||||
default:
|
|
||||||
return CustomFlow.CustomRequestDetail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Requests Filters component for a user filter type
|
|
||||||
* Each user type can have its own filter component
|
|
||||||
*
|
|
||||||
* This allows for plug-and-play filter components:
|
|
||||||
* - DEALER: Simplified filters (search + sort only)
|
|
||||||
* - STANDARD: Full filters (search + status + priority + template + sort)
|
|
||||||
*
|
|
||||||
* To add a new user filter type:
|
|
||||||
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
|
||||||
* 2. Create a filter component in the appropriate flow folder
|
|
||||||
* 3. Export it from the flow's index.ts
|
|
||||||
* 4. Add a case here to return it
|
|
||||||
*/
|
|
||||||
export function getRequestsFilters(userFilterType: UserFilterType) {
|
|
||||||
switch (userFilterType) {
|
|
||||||
case 'DEALER':
|
|
||||||
return DealerClaimFlow.DealerRequestsFilters;
|
|
||||||
case 'STANDARD':
|
|
||||||
default:
|
|
||||||
return CustomFlow.StandardRequestsFilters;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Closed Requests Filters component for a user filter type
|
|
||||||
* Each user type can have its own filter component for closed requests
|
|
||||||
*
|
|
||||||
* This allows for plug-and-play filter components:
|
|
||||||
* - DEALER: Simplified filters (search + status + sort only, no priority or template)
|
|
||||||
* - STANDARD: Full filters (search + priority + status + template + sort)
|
|
||||||
*
|
|
||||||
* To add a new user filter type:
|
|
||||||
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
|
||||||
* 2. Create a closed requests filter component in the appropriate flow folder
|
|
||||||
* 3. Export it from the flow's index.ts
|
|
||||||
* 4. Add a case here to return it
|
|
||||||
*/
|
|
||||||
export function getClosedRequestsFilters(userFilterType: UserFilterType) {
|
|
||||||
switch (userFilterType) {
|
|
||||||
case 'DEALER':
|
|
||||||
return DealerClaimFlow.DealerClosedRequestsFilters;
|
|
||||||
case 'STANDARD':
|
|
||||||
default:
|
|
||||||
return CustomFlow.StandardClosedRequestsFilters;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get User All Requests Filters component for a user filter type
|
|
||||||
* Each user type can have its own filter component for user all requests
|
|
||||||
*
|
|
||||||
* This allows for plug-and-play filter components:
|
|
||||||
* - DEALER: Simplified filters (search + status + initiator + approver + date range, no priority/template/department/sla)
|
|
||||||
* - STANDARD: Full filters (all filters including priority, template, department, and SLA compliance)
|
|
||||||
*
|
|
||||||
* To add a new user filter type:
|
|
||||||
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
|
||||||
* 2. Create a user all requests filter component in the appropriate flow folder
|
|
||||||
* 3. Export it from the flow's index.ts
|
|
||||||
* 4. Add a case here to return it
|
|
||||||
*/
|
|
||||||
export function getUserAllRequestsFilters(userFilterType: UserFilterType) {
|
|
||||||
switch (userFilterType) {
|
|
||||||
case 'DEALER':
|
|
||||||
return DealerClaimFlow.DealerUserAllRequestsFilters;
|
|
||||||
case 'STANDARD':
|
|
||||||
default:
|
|
||||||
return CustomFlow.StandardUserAllRequestsFilters;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export flow modules for direct access
|
|
||||||
export { CustomFlow, DealerClaimFlow, SharedComponents };
|
|
||||||
export type { RequestFlowType } from '@/utils/requestTypeUtils';
|
|
||||||
export type { UserFilterType } from '@/utils/userFilterUtils';
|
|
||||||
@ -42,18 +42,6 @@ export function useConclusionRemark(
|
|||||||
// State: Tracks if current conclusion was AI-generated (shows badge in UI)
|
// State: Tracks if current conclusion was AI-generated (shows badge in UI)
|
||||||
const [aiGenerated, setAiGenerated] = useState(false);
|
const [aiGenerated, setAiGenerated] = useState(false);
|
||||||
|
|
||||||
// State: Tracks number of AI generation attempts
|
|
||||||
const [generationAttempts, setGenerationAttempts] = useState(0);
|
|
||||||
|
|
||||||
// State: Tracks if AI generation failed (unable to generate)
|
|
||||||
const [generationFailed, setGenerationFailed] = useState(false);
|
|
||||||
|
|
||||||
// State: Tracks if max attempts (3 for success, 1 for fail) reached
|
|
||||||
const [maxAttemptsReached, setMaxAttemptsReached] = useState(false);
|
|
||||||
|
|
||||||
// State: Tracks number of AI generation failures
|
|
||||||
const [failureAttempts, setFailureAttempts] = useState(0);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function: fetchExistingConclusion
|
* Function: fetchExistingConclusion
|
||||||
*
|
*
|
||||||
@ -62,46 +50,26 @@ export function useConclusionRemark(
|
|||||||
* Use Case: When request is approved, final approver generates conclusion.
|
* Use Case: When request is approved, final approver generates conclusion.
|
||||||
* Initiator needs to review and finalize it before closing request.
|
* Initiator needs to review and finalize it before closing request.
|
||||||
*
|
*
|
||||||
* Optimization: Check request object first before making API call
|
|
||||||
* Process:
|
* Process:
|
||||||
* 1. Check if conclusion data is already in request object
|
* 1. Dynamically import conclusion API service
|
||||||
* 2. If not available, fetch from API
|
* 2. Fetch conclusion by request ID
|
||||||
* 3. Load into state if exists
|
* 3. Load into state if exists
|
||||||
* 4. Mark as AI-generated if applicable
|
* 4. Mark as AI-generated if applicable
|
||||||
*/
|
*/
|
||||||
const fetchExistingConclusion = async () => {
|
const fetchExistingConclusion = async () => {
|
||||||
// Optimization: Check if conclusion data is already in request object
|
|
||||||
// Request detail response includes conclusionRemark and aiGeneratedConclusion fields
|
|
||||||
const existingConclusion = request?.conclusionRemark || request?.conclusion_remark;
|
|
||||||
const existingAiConclusion = request?.aiGeneratedConclusion || request?.ai_generated_conclusion;
|
|
||||||
|
|
||||||
if (existingConclusion || existingAiConclusion) {
|
|
||||||
// Use data from request object - no API call needed
|
|
||||||
setConclusionRemark(existingConclusion || existingAiConclusion);
|
|
||||||
setAiGenerated(!!existingAiConclusion);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only fetch from API if not available in request object
|
|
||||||
// This handles cases where request object might not have been refreshed yet
|
|
||||||
try {
|
try {
|
||||||
// Lazy load: Import conclusion API only when needed
|
// Lazy load: Import conclusion API only when needed
|
||||||
const { getConclusion } = await import('@/services/conclusionApi');
|
const { getConclusion } = await import('@/services/conclusionApi');
|
||||||
|
|
||||||
// API Call: Fetch existing conclusion (returns null if not found)
|
// API Call: Fetch existing conclusion
|
||||||
const result = await getConclusion(request.requestId || requestIdentifier);
|
const result = await getConclusion(request.requestId || requestIdentifier);
|
||||||
|
|
||||||
if (result && (result.aiGeneratedRemark || result.finalRemark)) {
|
if (result && result.aiGeneratedRemark) {
|
||||||
// Load: Set the AI-generated or final remark
|
// Load: Set the AI-generated or final remark
|
||||||
// Handle null values by providing empty string fallback
|
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark);
|
||||||
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark || '');
|
|
||||||
setAiGenerated(!!result.aiGeneratedRemark);
|
setAiGenerated(!!result.aiGeneratedRemark);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Only log non-404 errors (404 is handled gracefully in API)
|
|
||||||
if ((err as any)?.response?.status !== 404) {
|
|
||||||
console.error('[useConclusionRemark] Error fetching conclusion:', err);
|
|
||||||
}
|
|
||||||
// No conclusion yet - this is expected for newly approved requests
|
// No conclusion yet - this is expected for newly approved requests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -125,12 +93,8 @@ export function useConclusionRemark(
|
|||||||
* 5. Handle errors silently (user can type manually)
|
* 5. Handle errors silently (user can type manually)
|
||||||
*/
|
*/
|
||||||
const handleGenerateConclusion = async () => {
|
const handleGenerateConclusion = async () => {
|
||||||
// Safety check: Prevent generation if max attempts already reached
|
|
||||||
if (maxAttemptsReached) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setConclusionLoading(true);
|
setConclusionLoading(true);
|
||||||
setGenerationFailed(false);
|
|
||||||
|
|
||||||
// Lazy load: Import conclusion API
|
// Lazy load: Import conclusion API
|
||||||
const { generateConclusion } = await import('@/services/conclusionApi');
|
const { generateConclusion } = await import('@/services/conclusionApi');
|
||||||
@ -138,74 +102,14 @@ export function useConclusionRemark(
|
|||||||
// API Call: Generate AI conclusion based on request data
|
// API Call: Generate AI conclusion based on request data
|
||||||
const result = await generateConclusion(request.requestId || requestIdentifier);
|
const result = await generateConclusion(request.requestId || requestIdentifier);
|
||||||
|
|
||||||
const newAttempts = generationAttempts + 1;
|
|
||||||
setGenerationAttempts(newAttempts);
|
|
||||||
|
|
||||||
// Check for "unable to generate" or similar keywords in proper response
|
|
||||||
const isUnableToGenerate = !result?.aiGeneratedRemark ||
|
|
||||||
result.aiGeneratedRemark.toLowerCase().includes('unable to generate') ||
|
|
||||||
result.aiGeneratedRemark.toLowerCase().includes('sorry');
|
|
||||||
|
|
||||||
if (isUnableToGenerate) {
|
|
||||||
const newFailures = failureAttempts + 1;
|
|
||||||
setFailureAttempts(newFailures);
|
|
||||||
|
|
||||||
if (newFailures >= 2) {
|
|
||||||
setMaxAttemptsReached(true);
|
|
||||||
setActionStatus?.({
|
|
||||||
success: false,
|
|
||||||
title: 'AI Generation Limit Reached',
|
|
||||||
message: "We're unable to process a conclusion remark at this time after 2 attempts. Please proceed with a manual approach using the editor below."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setActionStatus?.({
|
|
||||||
success: false,
|
|
||||||
title: 'System Note',
|
|
||||||
message: "We're unable to process a conclusion remark at the moment. You have one more attempt remaining, or you can proceed manually."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setShowActionStatusModal?.(true);
|
|
||||||
setConclusionRemark(result?.aiGeneratedRemark || '');
|
|
||||||
setAiGenerated(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success: Load AI-generated remark
|
// Success: Load AI-generated remark
|
||||||
setConclusionRemark(result.aiGeneratedRemark);
|
setConclusionRemark(result.aiGeneratedRemark);
|
||||||
setAiGenerated(true);
|
setAiGenerated(true);
|
||||||
setFailureAttempts(0); // Reset failures on success
|
|
||||||
|
|
||||||
// Limit to 2 successful attempts
|
|
||||||
if (newAttempts >= 2) {
|
|
||||||
setMaxAttemptsReached(true);
|
|
||||||
setActionStatus?.({
|
|
||||||
success: true,
|
|
||||||
title: 'Maximum Attempts Reached',
|
|
||||||
message: "You've reached the maximum of 2 regeneration attempts. Feel free to manually edit the current suggestion to fit your specific needs."
|
|
||||||
});
|
|
||||||
setShowActionStatusModal?.(true);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Fail silently: User can write conclusion manually
|
||||||
console.error('[useConclusionRemark] AI generation failed:', err);
|
console.error('[useConclusionRemark] AI generation failed:', err);
|
||||||
const newFailures = failureAttempts + 1;
|
setConclusionRemark('');
|
||||||
setFailureAttempts(newFailures);
|
|
||||||
setAiGenerated(false);
|
setAiGenerated(false);
|
||||||
|
|
||||||
if (newFailures >= 2) {
|
|
||||||
setMaxAttemptsReached(true);
|
|
||||||
setActionStatus?.({
|
|
||||||
success: false,
|
|
||||||
title: 'System Note',
|
|
||||||
message: "We're unable to process your request at the moment. Since the maximum of 2 attempts is reached, please proceed with a manual approach."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setActionStatus?.({
|
|
||||||
success: false,
|
|
||||||
title: 'System Note',
|
|
||||||
message: "We're unable to process your request at the moment. You have one more attempt remaining, or you can proceed manually."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setShowActionStatusModal?.(true);
|
|
||||||
} finally {
|
} finally {
|
||||||
setConclusionLoading(false);
|
setConclusionLoading(false);
|
||||||
}
|
}
|
||||||
@ -314,36 +218,16 @@ export function useConclusionRemark(
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect: Auto-load existing conclusion when request becomes approved, rejected, or closed
|
* Effect: Auto-fetch existing conclusion when request becomes approved or rejected
|
||||||
*
|
*
|
||||||
* Trigger: When request status changes to "approved", "rejected", or "closed" and user is initiator
|
* Trigger: When request status changes to "approved" or "rejected" and user is initiator
|
||||||
* Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected)
|
* Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected)
|
||||||
*
|
|
||||||
* Optimization:
|
|
||||||
* 1. First check if conclusion data is already in request object (no API call needed)
|
|
||||||
* 2. Only fetch from API if not available in request object
|
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const status = request?.status?.toLowerCase();
|
if ((request?.status === 'approved' || request?.status === 'rejected') && isInitiator && !conclusionRemark) {
|
||||||
const shouldLoad = (status === 'approved' || status === 'rejected' || status === 'closed')
|
|
||||||
&& isInitiator
|
|
||||||
&& !conclusionRemark;
|
|
||||||
|
|
||||||
if (!shouldLoad) return;
|
|
||||||
|
|
||||||
// Check if conclusion data is already in request object
|
|
||||||
const existingConclusion = request?.conclusionRemark || request?.conclusion_remark;
|
|
||||||
const existingAiConclusion = request?.aiGeneratedConclusion || request?.ai_generated_conclusion;
|
|
||||||
|
|
||||||
if (existingConclusion || existingAiConclusion) {
|
|
||||||
// Use data from request object - no API call needed
|
|
||||||
setConclusionRemark(existingConclusion || existingAiConclusion);
|
|
||||||
setAiGenerated(!!existingAiConclusion);
|
|
||||||
} else {
|
|
||||||
// Only fetch from API if not available in request object
|
|
||||||
fetchExistingConclusion();
|
fetchExistingConclusion();
|
||||||
}
|
}
|
||||||
}, [request?.status, request?.conclusionRemark, request?.aiGeneratedConclusion, isInitiator, conclusionRemark]);
|
}, [request?.status, isInitiator]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conclusionRemark,
|
conclusionRemark,
|
||||||
@ -352,10 +236,7 @@ export function useConclusionRemark(
|
|||||||
conclusionSubmitting,
|
conclusionSubmitting,
|
||||||
aiGenerated,
|
aiGenerated,
|
||||||
handleGenerateConclusion,
|
handleGenerateConclusion,
|
||||||
handleFinalizeConclusion,
|
handleFinalizeConclusion
|
||||||
generationAttempts,
|
|
||||||
generationFailed,
|
|
||||||
maxAttemptsReached
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -163,9 +163,9 @@ export function useCreateRequestForm(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load system policy
|
// Load system policy
|
||||||
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
const workflowConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
||||||
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
const tatConfigs = await getPublicConfigurations('TAT_SETTINGS');
|
||||||
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
const allConfigs = [...workflowConfigs, ...tatConfigs];
|
||||||
const configMap: Record<string, string> = {};
|
const configMap: Record<string, string> = {};
|
||||||
allConfigs.forEach((c: AdminConfiguration) => {
|
allConfigs.forEach((c: AdminConfiguration) => {
|
||||||
configMap[c.configKey] = c.configValue;
|
configMap[c.configKey] = c.configValue;
|
||||||
|
|||||||
@ -85,10 +85,6 @@ export function useModalManager(
|
|||||||
// API Call: Submit approval
|
// API Call: Submit approval
|
||||||
await approveLevel(requestIdentifier, levelId, description || '');
|
await approveLevel(requestIdentifier, levelId, description || '');
|
||||||
|
|
||||||
// Small delay to ensure backend has fully processed the approval and updated the status
|
|
||||||
// This is especially important for additional approvers where the workflow moves to the next step
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
// Refresh: Update UI with new approval status
|
// Refresh: Update UI with new approval status
|
||||||
await refreshDetails();
|
await refreshDetails();
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
||||||
import apiClient from '@/services/authApi';
|
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||||
import { getSocket } from '@/utils/socket';
|
import { getSocket } from '@/utils/socket';
|
||||||
@ -219,67 +218,15 @@ export function useRequestDetails(
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch: Get pause details only if request is actually paused
|
* Fetch: Get pause details if request is paused
|
||||||
* Use request-level isPaused field from workflow response
|
* This is needed to show resume/retrigger buttons correctly
|
||||||
*/
|
*/
|
||||||
let pauseInfo = null;
|
let pauseInfo = null;
|
||||||
const isPaused = (wf as any).isPaused || false;
|
try {
|
||||||
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
if (isPaused) {
|
} catch (error) {
|
||||||
try {
|
// Pause info not available or request not paused - ignore
|
||||||
pauseInfo = await getPauseDetails(wf.requestId);
|
console.debug('Pause details not available:', error);
|
||||||
} catch (error) {
|
|
||||||
// Pause info not available - ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch: Get claim details if this is a claim management request
|
|
||||||
*/
|
|
||||||
let claimDetails = null;
|
|
||||||
let proposalDetails = null;
|
|
||||||
let completionDetails = null;
|
|
||||||
let internalOrder = null;
|
|
||||||
|
|
||||||
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
|
||||||
try {
|
|
||||||
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
|
||||||
|
|
||||||
const claimData = claimResponse.data?.data || claimResponse.data;
|
|
||||||
|
|
||||||
if (claimData) {
|
|
||||||
claimDetails = claimData.claimDetails || claimData.claim_details;
|
|
||||||
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
|
||||||
completionDetails = claimData.completionDetails || claimData.completion_details;
|
|
||||||
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
|
||||||
// New normalized tables
|
|
||||||
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
|
||||||
const invoice = claimData.invoice || null;
|
|
||||||
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
|
||||||
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
|
||||||
|
|
||||||
// Store new fields in claimDetails for backward compatibility and easy access
|
|
||||||
if (claimDetails) {
|
|
||||||
(claimDetails as any).budgetTracking = budgetTracking;
|
|
||||||
(claimDetails as any).invoice = invoice;
|
|
||||||
(claimDetails as any).creditNote = creditNote;
|
|
||||||
(claimDetails as any).completionExpenses = completionExpenses;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extracted details processed
|
|
||||||
} else {
|
|
||||||
console.warn('[useRequestDetails] No claimData found in response');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// Claim details not available - request might not be fully initialized yet
|
|
||||||
console.error('[useRequestDetails] Error fetching claim details:', {
|
|
||||||
error: error?.message || error,
|
|
||||||
status: error?.response?.status,
|
|
||||||
statusText: error?.response?.statusText,
|
|
||||||
responseData: error?.response?.data,
|
|
||||||
requestId: wf.requestId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -295,16 +242,12 @@ export function useRequestDetails(
|
|||||||
description: wf.description,
|
description: wf.description,
|
||||||
status: statusMap(wf.status),
|
status: statusMap(wf.status),
|
||||||
priority: (wf.priority || '').toString().toLowerCase(),
|
priority: (wf.priority || '').toString().toLowerCase(),
|
||||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
|
||||||
approvalFlow,
|
approvalFlow,
|
||||||
approvals, // Raw approvals for SLA calculations
|
approvals, // Raw approvals for SLA calculations
|
||||||
participants,
|
participants,
|
||||||
documents: mappedDocuments,
|
documents: mappedDocuments,
|
||||||
spectators,
|
spectators,
|
||||||
summary, // Backend-provided SLA summary
|
summary, // Backend-provided SLA summary
|
||||||
// Ensure SLA is available at root level for RequestDetailHeader
|
|
||||||
// Backend provides full SLA in summary.sla with all required fields
|
|
||||||
sla: summary?.sla || wf.sla || null,
|
|
||||||
initiator: {
|
initiator: {
|
||||||
name: wf.initiator?.displayName || wf.initiator?.email,
|
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||||
role: wf.initiator?.designation || undefined,
|
role: wf.initiator?.designation || undefined,
|
||||||
@ -323,16 +266,6 @@ export function useRequestDetails(
|
|||||||
conclusionRemark: wf.conclusionRemark || null,
|
conclusionRemark: wf.conclusionRemark || null,
|
||||||
closureDate: wf.closureDate || null,
|
closureDate: wf.closureDate || null,
|
||||||
pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons
|
pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons
|
||||||
// Claim management specific data
|
|
||||||
claimDetails: claimDetails || null,
|
|
||||||
proposalDetails: proposalDetails || null,
|
|
||||||
completionDetails: completionDetails || null,
|
|
||||||
internalOrder: internalOrder || null,
|
|
||||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
|
||||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
|
||||||
invoice: (claimDetails as any)?.invoice || null,
|
|
||||||
creditNote: (claimDetails as any)?.creditNote || null,
|
|
||||||
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setApiRequest(updatedRequest);
|
setApiRequest(updatedRequest);
|
||||||
@ -499,61 +432,13 @@ export function useRequestDetails(
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Fetch pause details only if request is actually paused
|
// Fetch pause details
|
||||||
// Use request-level isPaused field from workflow response
|
|
||||||
let pauseInfo = null;
|
let pauseInfo = null;
|
||||||
const isPaused = (wf as any).isPaused || false;
|
try {
|
||||||
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
if (isPaused) {
|
} catch (error) {
|
||||||
try {
|
// Pause info not available or request not paused - ignore
|
||||||
pauseInfo = await getPauseDetails(wf.requestId);
|
console.debug('Pause details not available:', error);
|
||||||
} catch (error) {
|
|
||||||
// Pause info not available - ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch: Get claim details if this is a claim management request
|
|
||||||
*/
|
|
||||||
let claimDetails = null;
|
|
||||||
let proposalDetails = null;
|
|
||||||
let completionDetails = null;
|
|
||||||
let internalOrder = null;
|
|
||||||
|
|
||||||
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
|
||||||
try {
|
|
||||||
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
|
||||||
|
|
||||||
const claimData = claimResponse.data?.data || claimResponse.data;
|
|
||||||
if (claimData) {
|
|
||||||
claimDetails = claimData.claimDetails || claimData.claim_details;
|
|
||||||
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
|
||||||
completionDetails = claimData.completionDetails || claimData.completion_details;
|
|
||||||
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
|
||||||
// New normalized tables
|
|
||||||
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
|
||||||
const invoice = claimData.invoice || null;
|
|
||||||
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
|
||||||
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
|
||||||
|
|
||||||
// Store new fields in claimDetails for backward compatibility and easy access
|
|
||||||
if (claimDetails) {
|
|
||||||
(claimDetails as any).budgetTracking = budgetTracking;
|
|
||||||
(claimDetails as any).invoice = invoice;
|
|
||||||
(claimDetails as any).creditNote = creditNote;
|
|
||||||
(claimDetails as any).completionExpenses = completionExpenses;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial load - Extracted details processed
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// Claim details not available - request might not be fully initialized yet
|
|
||||||
console.error('[useRequestDetails] Initial load - Error fetching claim details:', {
|
|
||||||
error: error?.message || error,
|
|
||||||
status: error?.response?.status,
|
|
||||||
requestId: wf.requestId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build complete request object
|
// Build complete request object
|
||||||
@ -564,7 +449,6 @@ export function useRequestDetails(
|
|||||||
description: wf.description,
|
description: wf.description,
|
||||||
priority,
|
priority,
|
||||||
status: statusMap(wf.status),
|
status: statusMap(wf.status),
|
||||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
|
||||||
summary,
|
summary,
|
||||||
initiator: {
|
initiator: {
|
||||||
name: wf.initiator?.displayName || wf.initiator?.email,
|
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||||
@ -588,16 +472,6 @@ export function useRequestDetails(
|
|||||||
conclusionRemark: wf.conclusionRemark || null,
|
conclusionRemark: wf.conclusionRemark || null,
|
||||||
closureDate: wf.closureDate || null,
|
closureDate: wf.closureDate || null,
|
||||||
pauseInfo: pauseInfo || null,
|
pauseInfo: pauseInfo || null,
|
||||||
// Claim management specific data
|
|
||||||
claimDetails: claimDetails || null,
|
|
||||||
proposalDetails: proposalDetails || null,
|
|
||||||
completionDetails: completionDetails || null,
|
|
||||||
internalOrder: internalOrder || null,
|
|
||||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
|
||||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
|
||||||
invoice: (claimDetails as any)?.invoice || null,
|
|
||||||
creditNote: (claimDetails as any)?.creditNote || null,
|
|
||||||
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setApiRequest(mapped);
|
setApiRequest(mapped);
|
||||||
@ -765,26 +639,35 @@ export function useRequestDetails(
|
|||||||
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
|
console.warn('[useRequestDetails] Socket not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useRequestDetails] Setting up socket listener for request:', apiRequest.requestId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler: Request updated by another user
|
* Handler: Request updated by another user
|
||||||
* Silently refresh to show latest changes
|
* Silently refresh to show latest changes
|
||||||
*/
|
*/
|
||||||
const handleRequestUpdated = (data: any) => {
|
const handleRequestUpdated = (data: any) => {
|
||||||
|
console.log('[useRequestDetails] 📡 Received request:updated event:', data);
|
||||||
// Verify this update is for the current request
|
// Verify this update is for the current request
|
||||||
if (data?.requestId === apiRequest.requestId || data?.requestNumber === requestIdentifier) {
|
if (data?.requestId === apiRequest.requestId || data?.requestNumber === requestIdentifier) {
|
||||||
|
console.log('[useRequestDetails] 🔄 Request updated remotely, refreshing silently...');
|
||||||
// Silent refresh - no loading state, no user interruption
|
// Silent refresh - no loading state, no user interruption
|
||||||
refreshDetails();
|
refreshDetails();
|
||||||
|
} else {
|
||||||
|
console.log('[useRequestDetails] ⚠️ Event for different request, ignoring');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register listener
|
// Register listener
|
||||||
socket.on('request:updated', handleRequestUpdated);
|
socket.on('request:updated', handleRequestUpdated);
|
||||||
|
console.log('[useRequestDetails] ✅ Registered listener for request:updated');
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[useRequestDetails] 🧹 Cleaning up socket listener');
|
||||||
socket.off('request:updated', handleRequestUpdated);
|
socket.off('request:updated', handleRequestUpdated);
|
||||||
};
|
};
|
||||||
}, [requestIdentifier, apiRequest, refreshDetails]);
|
}, [requestIdentifier, apiRequest, refreshDetails]);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { AuthProvider } from './contexts/AuthContext';
|
|||||||
import { AuthenticatedApp } from './pages/Auth';
|
import { AuthenticatedApp } from './pages/Auth';
|
||||||
import { store } from './redux/store';
|
import { store } from './redux/store';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
import './styles/base-layout.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@ -1,12 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus, Pencil, Search, FileText } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search, FileText, AlertTriangle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { getTemplates, WorkflowTemplate, getCachedTemplates } from '@/services/workflowTemplateApi';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { getTemplates, deleteTemplate, WorkflowTemplate, getCachedTemplates } from '@/services/workflowTemplateApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function AdminTemplatesList() {
|
export function AdminTemplatesList() {
|
||||||
@ -15,6 +25,8 @@ export function AdminTemplatesList() {
|
|||||||
// Only show full loading skeleton if we don't have any data yet
|
// Only show full loading skeleton if we don't have any data yet
|
||||||
const [loading, setLoading] = useState(() => !getCachedTemplates());
|
const [loading, setLoading] = useState(() => !getCachedTemplates());
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
try {
|
try {
|
||||||
@ -37,6 +49,22 @@ export function AdminTemplatesList() {
|
|||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(true);
|
||||||
|
await deleteTemplate(deleteId);
|
||||||
|
toast.success('Template deleted successfully');
|
||||||
|
setTemplates(prev => prev.filter(t => t.id !== deleteId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete template:', error);
|
||||||
|
toast.error('Failed to delete template');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredTemplates = templates.filter(template =>
|
const filteredTemplates = templates.filter(template =>
|
||||||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@ -124,7 +152,7 @@ export function AdminTemplatesList() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="line-clamp-1 text-lg">{template.name}</CardTitle>
|
<CardTitle className="line-clamp-1 text-lg">{template.name}</CardTitle>
|
||||||
<CardDescription className="line-clamp-3 min-h-[4.5rem]">
|
<CardDescription className="line-clamp-2 h-10">
|
||||||
{template.description}
|
{template.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -153,6 +181,14 @@ export function AdminTemplatesList() {
|
|||||||
<Pencil className="w-4 h-4 mr-2" />
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-100"
|
||||||
|
onClick={() => setDeleteId(template.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -160,6 +196,33 @@ export function AdminTemplatesList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
Delete Template
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this template? This action cannot be undone.
|
||||||
|
Active requests using this template will not be affected.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -327,7 +327,7 @@ export function CreateTemplate() {
|
|||||||
placeholder={approver.tatType === 'days' ? '1' : '24'}
|
placeholder={approver.tatType === 'days' ? '1' : '24'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = parseInt(e.target.value) || 0;
|
const val = parseInt(e.target.value) || 0;
|
||||||
// const max = approver.tatType === 'days' ? 7 : 24;
|
const max = approver.tatType === 'days' ? 7 : 24;
|
||||||
// Optional: strict clamping or just allow typing and validate later
|
// Optional: strict clamping or just allow typing and validate later
|
||||||
// For better UX, let's allow typing but validate in isFormValid
|
// For better UX, let's allow typing but validate in isFormValid
|
||||||
// But prevent entering negative numbers
|
// But prevent entering negative numbers
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import { Pagination } from '@/components/common/Pagination';
|
|||||||
import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers';
|
import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers';
|
||||||
import { formatDate, formatDateTime } from '../utils/formatters';
|
import { formatDate, formatDateTime } from '../utils/formatters';
|
||||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||||
import { navigateToRequest } from '@/utils/requestNavigation';
|
|
||||||
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
|
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
|
||||||
|
|
||||||
interface ApproverPerformanceRequestListProps {
|
interface ApproverPerformanceRequestListProps {
|
||||||
@ -69,15 +68,7 @@ export function ApproverPerformanceRequestList({
|
|||||||
<Card
|
<Card
|
||||||
key={request.requestId}
|
key={request.requestId}
|
||||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => navigate(`/request/${request.requestId}`)}
|
||||||
navigateToRequest({
|
|
||||||
requestId: request.requestId,
|
|
||||||
requestTitle: request.title,
|
|
||||||
status: request.status,
|
|
||||||
request: request,
|
|
||||||
navigate,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
data-testid={`request-card-${request.requestId}`}
|
data-testid={`request-card-${request.requestId}`}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@ -166,13 +157,7 @@ export function ApproverPerformanceRequestList({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigateToRequest({
|
navigate(`/request/${request.requestId}`);
|
||||||
requestId: request.requestId,
|
|
||||||
requestTitle: request.title,
|
|
||||||
status: request.status,
|
|
||||||
request: request,
|
|
||||||
navigate,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
data-testid="view-request-button"
|
data-testid="view-request-button"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,30 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { LogIn } from 'lucide-react';
|
import { LogIn } from 'lucide-react';
|
||||||
import { ReLogo, LandingPageImage } from '@/assets';
|
import { ReLogo } from '@/assets';
|
||||||
// import { initiateTanflowLogin } from '@/services/tanflowAuth';
|
|
||||||
|
|
||||||
export function Auth() {
|
export function Auth() {
|
||||||
const { login, isLoading, error } = useAuth();
|
const { login, isLoading, error } = useAuth();
|
||||||
const [tanflowLoading] = useState(false);
|
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
|
||||||
|
|
||||||
// Preload the background image
|
const handleSSOLogin = async () => {
|
||||||
useEffect(() => {
|
|
||||||
const img = new Image();
|
|
||||||
img.src = LandingPageImage;
|
|
||||||
img.onload = () => {
|
|
||||||
setImageLoaded(true);
|
|
||||||
};
|
|
||||||
// If image is already cached, trigger load immediately
|
|
||||||
if (img.complete) {
|
|
||||||
setImageLoaded(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOKTALogin = async () => {
|
|
||||||
// Clear any existing session data
|
// Clear any existing session data
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
@ -33,7 +16,7 @@ export function Auth() {
|
|||||||
await login();
|
await login();
|
||||||
} catch (loginError) {
|
} catch (loginError) {
|
||||||
console.error('========================================');
|
console.error('========================================');
|
||||||
console.error('OKTA LOGIN ERROR');
|
console.error('LOGIN ERROR');
|
||||||
console.error('Error details:', loginError);
|
console.error('Error details:', loginError);
|
||||||
console.error('Error message:', (loginError as Error)?.message);
|
console.error('Error message:', (loginError as Error)?.message);
|
||||||
console.error('Error stack:', (loginError as Error)?.stack);
|
console.error('Error stack:', (loginError as Error)?.stack);
|
||||||
@ -41,48 +24,16 @@ export function Auth() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* const handleTanflowLogin = () => {
|
|
||||||
// Clear any existing session data
|
|
||||||
localStorage.clear();
|
|
||||||
sessionStorage.clear();
|
|
||||||
|
|
||||||
setTanflowLoading(true);
|
|
||||||
try {
|
|
||||||
initiateTanflowLogin();
|
|
||||||
} catch (loginError) {
|
|
||||||
console.error('========================================');
|
|
||||||
console.error('TANFLOW LOGIN ERROR');
|
|
||||||
console.error('Error details:', loginError);
|
|
||||||
setTanflowLoading(false);
|
|
||||||
}
|
|
||||||
}; */
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Auth Error in Auth Component:', {
|
console.error('Auth0 Error in Auth Component:', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
error: error
|
error: error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
||||||
className="min-h-screen flex items-center justify-center p-4 relative"
|
<Card className="w-full max-w-md shadow-xl">
|
||||||
style={{
|
|
||||||
backgroundImage: imageLoaded ? `url(${LandingPageImage})` : 'none',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
transition: 'background-image 0.3s ease-in-out'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Fallback background while image loads */}
|
|
||||||
{!imageLoaded && (
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 to-slate-800"></div>
|
|
||||||
)}
|
|
||||||
{/* Overlay for better readability */}
|
|
||||||
<div className="absolute inset-0 bg-black/40"></div>
|
|
||||||
|
|
||||||
<Card className="w-full max-w-md shadow-xl relative z-10 bg-black backdrop-blur-sm border-gray-800">
|
|
||||||
<CardHeader className="space-y-1 text-center pb-6">
|
<CardHeader className="space-y-1 text-center pb-6">
|
||||||
<div className="flex flex-col items-center justify-center mb-4">
|
<div className="flex flex-col items-center justify-center mb-4">
|
||||||
<img
|
<img
|
||||||
@ -90,74 +41,42 @@ export function Auth() {
|
|||||||
alt="Royal Enfield Logo"
|
alt="Royal Enfield Logo"
|
||||||
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-300 text-center truncate">Approval Portal</p>
|
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-900/50 border border-red-700 text-red-200 px-4 py-3 rounded-lg">
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||||
<p className="text-sm font-medium">Authentication Error</p>
|
<p className="text-sm font-medium">Authentication Error</p>
|
||||||
<p className="text-sm">{error.message}</p>
|
<p className="text-sm">{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<Button
|
||||||
<Button
|
onClick={handleSSOLogin}
|
||||||
onClick={handleOKTALogin}
|
disabled={isLoading}
|
||||||
disabled={isLoading || tanflowLoading}
|
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
|
||||||
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
|
size="lg"
|
||||||
size="lg"
|
>
|
||||||
>
|
{isLoading ? (
|
||||||
{isLoading ? (
|
<>
|
||||||
<>
|
<div
|
||||||
<div
|
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
/>
|
||||||
/>
|
Logging in...
|
||||||
Logging in...
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<LogIn className="mr-2 h-5 w-5" />
|
||||||
<LogIn className="mr-2 h-5 w-5" />
|
SSO Login
|
||||||
RE Employee Login
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
|
||||||
{/*
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-gray-700"></span>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-gray-900 px-2 text-gray-400">Or</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<div className="text-center text-sm text-gray-500 mt-4">
|
||||||
onClick={handleTanflowLogin}
|
|
||||||
disabled={isLoading || tanflowLoading}
|
|
||||||
className="w-full h-12 text-base font-semibold bg-indigo-600 hover:bg-indigo-700 text-white"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{tanflowLoading ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
|
||||||
/>
|
|
||||||
Redirecting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Shield className="mr-2 h-5 w-5" />
|
|
||||||
Dealer Login
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-400 mt-4">
|
|
||||||
<p>Secure Single Sign-On</p>
|
<p>Secure Single Sign-On</p>
|
||||||
<p className="text-xs mt-1 text-gray-500">Choose your authentication provider</p>
|
<p className="text-xs mt-1">Powered by Auth0</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Auth } from './Auth';
|
import { Auth } from './Auth';
|
||||||
import { AuthCallback } from './AuthCallback';
|
import { AuthCallback } from './AuthCallback';
|
||||||
import { TanflowCallback } from './TanflowCallback';
|
|
||||||
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
|
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
|
||||||
import App from '../../App';
|
import App from '../../App';
|
||||||
|
|
||||||
@ -11,8 +10,7 @@ export function AuthenticatedApp() {
|
|||||||
const [showDebugInfo, setShowDebugInfo] = useState(false);
|
const [showDebugInfo, setShowDebugInfo] = useState(false);
|
||||||
|
|
||||||
// Check if we're on callback route (after all hooks are called)
|
// Check if we're on callback route (after all hooks are called)
|
||||||
const isCallbackRoute = typeof window !== 'undefined' &&
|
const isCallbackRoute = typeof window !== 'undefined' && window.location.pathname === '/login/callback';
|
||||||
window.location.pathname === '/login/callback';
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@ -41,35 +39,7 @@ export function AuthenticatedApp() {
|
|||||||
}, [isAuthenticated, isLoading, error, user]);
|
}, [isAuthenticated, isLoading, error, user]);
|
||||||
|
|
||||||
// Always show callback loader when on callback route (after all hooks)
|
// Always show callback loader when on callback route (after all hooks)
|
||||||
// Detect provider from sessionStorage to show appropriate callback component
|
|
||||||
if (isCallbackRoute) {
|
if (isCallbackRoute) {
|
||||||
// Check if this is a logout redirect (no code, no error)
|
|
||||||
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
|
||||||
const hasCode = urlParams?.get('code');
|
|
||||||
const hasError = urlParams?.get('error');
|
|
||||||
|
|
||||||
// If no code and no error, it's a logout redirect - redirect immediately
|
|
||||||
if (!hasCode && !hasError) {
|
|
||||||
console.log('🚪 AuthenticatedApp: Logout redirect detected, redirecting to home');
|
|
||||||
const logoutParams = new URLSearchParams();
|
|
||||||
logoutParams.set('tanflow_logged_out', 'true');
|
|
||||||
logoutParams.set('logout', Date.now().toString());
|
|
||||||
window.location.replace(`/?${logoutParams.toString()}`);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-slate-900 border-t-transparent mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Redirecting...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authProvider = typeof window !== 'undefined' ? sessionStorage.getItem('auth_provider') : null;
|
|
||||||
if (authProvider === 'tanflow') {
|
|
||||||
return <TanflowCallback />;
|
|
||||||
}
|
|
||||||
// Default to OKTA callback (or if provider not set yet)
|
|
||||||
return <AuthCallback />;
|
return <AuthCallback />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,301 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tanflow OAuth Callback Handler
|
|
||||||
* Handles the redirect from Tanflow SSO after authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { exchangeTanflowCodeForTokens } from '@/services/tanflowAuth';
|
|
||||||
import { getCurrentUser } from '@/services/authApi';
|
|
||||||
import { TokenManager } from '@/utils/tokenManager';
|
|
||||||
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
|
||||||
import { ReLogo } from '@/assets';
|
|
||||||
|
|
||||||
export function TanflowCallback() {
|
|
||||||
const { isAuthenticated, isLoading, error, user } = useAuth();
|
|
||||||
const [authStep, setAuthStep] = useState<'exchanging' | 'fetching' | 'complete' | 'error'>('exchanging');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
|
||||||
const callbackProcessedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Determine current authentication step based on state
|
|
||||||
if (error) {
|
|
||||||
setAuthStep('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const hasCode = urlParams.get('code');
|
|
||||||
|
|
||||||
if (hasCode && !user) {
|
|
||||||
setAuthStep('exchanging');
|
|
||||||
} else if (user && !isAuthenticated) {
|
|
||||||
setAuthStep('fetching');
|
|
||||||
} else {
|
|
||||||
setAuthStep('exchanging');
|
|
||||||
}
|
|
||||||
} else if (user && isAuthenticated) {
|
|
||||||
setAuthStep('complete');
|
|
||||||
// If already authenticated, redirect immediately
|
|
||||||
// This handles the case where auth state was set before this component rendered
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/';
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, isLoading, error, user]);
|
|
||||||
|
|
||||||
// Handle Tanflow callback
|
|
||||||
useEffect(() => {
|
|
||||||
// Only process if we're on the callback route
|
|
||||||
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const code = urlParams.get('code');
|
|
||||||
const errorParam = urlParams.get('error');
|
|
||||||
|
|
||||||
// SIMPLIFIED: If no code and no error, it's a logout redirect - redirect immediately
|
|
||||||
// Tanflow logout redirects back to /login/callback without any parameters
|
|
||||||
if (!code && !errorParam) {
|
|
||||||
console.log('🚪 Logout redirect detected: no code, no error - redirecting to home immediately');
|
|
||||||
callbackProcessedRef.current = true;
|
|
||||||
|
|
||||||
// Redirect to home with logout flags
|
|
||||||
const logoutParams = new URLSearchParams();
|
|
||||||
logoutParams.set('tanflow_logged_out', 'true');
|
|
||||||
logoutParams.set('logout', Date.now().toString());
|
|
||||||
const redirectUrl = `/?${logoutParams.toString()}`;
|
|
||||||
|
|
||||||
console.log('🚪 Redirecting to:', redirectUrl);
|
|
||||||
window.location.replace(redirectUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a Tanflow callback
|
|
||||||
const authProvider = sessionStorage.getItem('auth_provider');
|
|
||||||
if (authProvider !== 'tanflow') {
|
|
||||||
// Not a Tanflow callback, let AuthContext handle it
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCallback = async () => {
|
|
||||||
callbackProcessedRef.current = true;
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const code = urlParams.get('code');
|
|
||||||
const state = urlParams.get('state');
|
|
||||||
const errorParam = urlParams.get('error');
|
|
||||||
|
|
||||||
// Clean URL immediately
|
|
||||||
window.history.replaceState({}, document.title, '/login/callback');
|
|
||||||
|
|
||||||
// Check for errors from Tanflow
|
|
||||||
if (errorParam) {
|
|
||||||
setAuthStep('error');
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
sessionStorage.removeItem('tanflow_auth_state');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate state
|
|
||||||
const storedState = sessionStorage.getItem('tanflow_auth_state');
|
|
||||||
if (state && state !== storedState) {
|
|
||||||
setAuthStep('error');
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
sessionStorage.removeItem('tanflow_auth_state');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
setAuthStep('error');
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
sessionStorage.removeItem('tanflow_auth_state');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAuthStep('exchanging');
|
|
||||||
|
|
||||||
// Exchange code for tokens (this stores tokens in TokenManager)
|
|
||||||
const tokenData = await exchangeTanflowCodeForTokens(code, state || '');
|
|
||||||
|
|
||||||
// Clear state but keep provider flag for logout detection
|
|
||||||
sessionStorage.removeItem('tanflow_auth_state');
|
|
||||||
// Keep auth_provider in sessionStorage so logout can detect which provider to use
|
|
||||||
// This will be cleared during logout
|
|
||||||
|
|
||||||
setAuthStep('fetching');
|
|
||||||
|
|
||||||
// Fetch user profile (tokenData already has user, but fetch to ensure it's current)
|
|
||||||
const userData = tokenData.user || await getCurrentUser();
|
|
||||||
|
|
||||||
if (userData) {
|
|
||||||
// Store user data in TokenManager (already stored by exchangeTanflowCodeForTokens, but ensure it's set)
|
|
||||||
TokenManager.setUserData(userData);
|
|
||||||
|
|
||||||
// Show success message briefly
|
|
||||||
setAuthStep('complete');
|
|
||||||
|
|
||||||
// Clean URL and do full page reload to ensure AuthContext checks auth status
|
|
||||||
// This is necessary because AuthContext skips auth check on /login/callback route
|
|
||||||
// After reload, AuthContext will check tokens and set isAuthenticated/user properly
|
|
||||||
setTimeout(() => {
|
|
||||||
window.history.replaceState({}, document.title, '/');
|
|
||||||
// Use window.location.href for full page reload to trigger AuthContext initialization
|
|
||||||
window.location.href = '/';
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
throw new Error('User data not received');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Tanflow callback error:', err);
|
|
||||||
setAuthStep('error');
|
|
||||||
setErrorMessage(err.message || 'Authentication failed');
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
sessionStorage.removeItem('tanflow_auth_state');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleCallback();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getLoadingMessage = () => {
|
|
||||||
switch (authStep) {
|
|
||||||
case 'exchanging':
|
|
||||||
return 'Exchanging authorization code...';
|
|
||||||
case 'fetching':
|
|
||||||
return 'Fetching your profile...';
|
|
||||||
case 'complete':
|
|
||||||
return 'Authentication successful!';
|
|
||||||
case 'error':
|
|
||||||
return 'Authentication failed';
|
|
||||||
default:
|
|
||||||
return 'Completing authentication...';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
|
||||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiMxZTIxMmQiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMzAiLz48L2c+PC9nPjwvc3ZnPg==')] opacity-20"></div>
|
|
||||||
|
|
||||||
<div className="relative z-10 text-center px-4 max-w-md w-full">
|
|
||||||
{/* Logo/Brand Section */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<img
|
|
||||||
src={ReLogo}
|
|
||||||
alt="Royal Enfield Logo"
|
|
||||||
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Loader Card */}
|
|
||||||
<div className="bg-white/10 backdrop-blur-xl rounded-2xl p-8 shadow-2xl border border-white/20">
|
|
||||||
{/* Status Icon */}
|
|
||||||
<div className="mb-6 flex justify-center">
|
|
||||||
{authStep === 'error' ? (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 animate-ping opacity-75">
|
|
||||||
<AlertCircle className="w-16 h-16 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<AlertCircle className="w-16 h-16 text-red-500 relative" />
|
|
||||||
</div>
|
|
||||||
) : authStep === 'complete' ? (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 animate-ping opacity-75">
|
|
||||||
<CheckCircle2 className="w-16 h-16 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<CheckCircle2 className="w-16 h-16 text-green-500 relative" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="relative">
|
|
||||||
<Loader2 className="w-16 h-16 animate-spin text-re-red" />
|
|
||||||
<div className="absolute inset-0 border-4 rounded-full border-re-red/20"></div>
|
|
||||||
<div className="absolute inset-0 border-4 border-transparent border-t-re-red rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading Message */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-2">
|
|
||||||
{authStep === 'complete' ? 'Welcome Back!' : authStep === 'error' ? 'Authentication Error' : 'Authenticating'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-300 text-sm">{getLoadingMessage()}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Steps */}
|
|
||||||
{authStep !== 'error' && (
|
|
||||||
<div className="space-y-3 mb-6">
|
|
||||||
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'exchanging' ? 'text-white' : 'text-slate-400'}`}>
|
|
||||||
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'exchanging' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
|
|
||||||
<span>Validating credentials</span>
|
|
||||||
</div>
|
|
||||||
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'fetching' ? 'text-white' : 'text-slate-400'}`}>
|
|
||||||
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'fetching' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
|
|
||||||
<span>Loading your profile</span>
|
|
||||||
</div>
|
|
||||||
{authStep === 'complete' && (
|
|
||||||
<div className="flex items-center gap-3 text-sm transition-all duration-500 text-white">
|
|
||||||
<div className="w-2 h-2 rounded-full transition-all duration-500 bg-green-500"></div>
|
|
||||||
<span>Setting up your session</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{authStep === 'error' && errorMessage && (
|
|
||||||
<div className="mt-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
|
||||||
<p className="text-red-400 text-sm">{errorMessage}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = '/';
|
|
||||||
}}
|
|
||||||
className="mt-4 text-sm text-red-400 hover:text-red-300 underline"
|
|
||||||
>
|
|
||||||
Return to login
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Animated Progress Bar */}
|
|
||||||
{authStep !== 'error' && authStep !== 'complete' && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-re-red rounded-full animate-pulse"
|
|
||||||
style={{
|
|
||||||
animation: 'progress 2s ease-in-out infinite',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<style>{`
|
|
||||||
@keyframes progress {
|
|
||||||
0%, 100% { width: 20%; }
|
|
||||||
50% { width: 80%; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Text */}
|
|
||||||
<p className="mt-6 text-slate-500 text-xs">
|
|
||||||
{authStep === 'complete' ? 'Loading dashboard...' : 'Please wait while we secure your session'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Animated Background Elements */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
||||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse"></div>
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useRef, useEffect, useMemo } from 'react';
|
import { useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
||||||
|
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
|
||||||
import { ClosedRequestsList } from './components/ClosedRequestsList';
|
import { ClosedRequestsList } from './components/ClosedRequestsList';
|
||||||
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
|
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
|
||||||
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
|
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
|
||||||
@ -13,11 +14,6 @@ import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
|
|||||||
// Types
|
// Types
|
||||||
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
||||||
|
|
||||||
// Utils & Factory
|
|
||||||
import { getUserFilterType } from '@/utils/userFilterUtils';
|
|
||||||
import { getClosedRequestsFilters } from '@/flows';
|
|
||||||
import { TokenManager } from '@/utils/tokenManager';
|
|
||||||
|
|
||||||
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||||
// Data fetching hook
|
// Data fetching hook
|
||||||
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
|
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
|
||||||
@ -27,29 +23,10 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
fetchRef.current = closedRequests.fetchRequests;
|
fetchRef.current = closedRequests.fetchRequests;
|
||||||
|
|
||||||
const filters = useClosedRequestsFilters();
|
const filters = useClosedRequestsFilters();
|
||||||
|
|
||||||
// Get user filter type and corresponding filter component (plug-and-play pattern)
|
|
||||||
const userFilterType = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const userData = TokenManager.getUserData();
|
|
||||||
return getUserFilterType(userData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ClosedRequests] Error getting user filter type:', error);
|
|
||||||
return 'STANDARD' as const;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get the appropriate filter component based on user type
|
|
||||||
const ClosedRequestsFiltersComponent = useMemo(() => {
|
|
||||||
return getClosedRequestsFilters(userFilterType);
|
|
||||||
}, [userFilterType]);
|
|
||||||
|
|
||||||
const isDealer = userFilterType === 'DEALER';
|
|
||||||
const prevFiltersRef = useRef({
|
const prevFiltersRef = useRef({
|
||||||
searchTerm: filters.searchTerm,
|
searchTerm: filters.searchTerm,
|
||||||
statusFilter: filters.statusFilter,
|
statusFilter: filters.statusFilter,
|
||||||
priorityFilter: filters.priorityFilter,
|
priorityFilter: filters.priorityFilter,
|
||||||
templateTypeFilter: filters.templateTypeFilter,
|
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
@ -61,15 +38,13 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
fetchRef.current(storedPage, {
|
fetchRef.current(storedPage, {
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
// Only include priority and templateType filters if user is not a dealer
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
sortBy: filters.sortBy,
|
||||||
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
sortOrder: filters.sortOrder,
|
||||||
sortBy: filters.sortBy,
|
});
|
||||||
sortOrder: filters.sortOrder,
|
|
||||||
});
|
|
||||||
hasInitialFetchRun.current = true;
|
hasInitialFetchRun.current = true;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isDealer]); // Re-fetch if dealer status changes
|
}, []); // Only on mount
|
||||||
|
|
||||||
// Track filter changes and refetch
|
// Track filter changes and refetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -80,7 +55,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
prev.searchTerm !== filters.searchTerm ||
|
prev.searchTerm !== filters.searchTerm ||
|
||||||
prev.statusFilter !== filters.statusFilter ||
|
prev.statusFilter !== filters.statusFilter ||
|
||||||
prev.priorityFilter !== filters.priorityFilter ||
|
prev.priorityFilter !== filters.priorityFilter ||
|
||||||
prev.templateTypeFilter !== filters.templateTypeFilter ||
|
|
||||||
prev.sortBy !== filters.sortBy ||
|
prev.sortBy !== filters.sortBy ||
|
||||||
prev.sortOrder !== filters.sortOrder;
|
prev.sortOrder !== filters.sortOrder;
|
||||||
|
|
||||||
@ -93,17 +67,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update previous values
|
// Update previous values
|
||||||
prevFiltersRef.current = {
|
prevFiltersRef.current = {
|
||||||
searchTerm: filters.searchTerm,
|
searchTerm: filters.searchTerm,
|
||||||
statusFilter: filters.statusFilter,
|
statusFilter: filters.statusFilter,
|
||||||
priorityFilter: filters.priorityFilter,
|
priorityFilter: filters.priorityFilter,
|
||||||
templateTypeFilter: filters.templateTypeFilter,
|
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
};
|
};
|
||||||
@ -111,7 +83,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.sortBy, filters.sortOrder]);
|
||||||
|
|
||||||
// Page change handler
|
// Page change handler
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
@ -122,7 +94,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
@ -137,7 +108,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
@ -153,25 +123,17 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Filters - Plug-and-play pattern */}
|
{/* Filters */}
|
||||||
<ClosedRequestsFiltersComponent
|
<ClosedRequestsFiltersComponent
|
||||||
searchTerm={filters.searchTerm}
|
searchTerm={filters.searchTerm}
|
||||||
priorityFilter={filters.priorityFilter}
|
priorityFilter={filters.priorityFilter}
|
||||||
statusFilter={filters.statusFilter}
|
statusFilter={filters.statusFilter}
|
||||||
templateTypeFilter={filters.templateTypeFilter}
|
|
||||||
sortBy={filters.sortBy}
|
sortBy={filters.sortBy}
|
||||||
sortOrder={filters.sortOrder}
|
sortOrder={filters.sortOrder}
|
||||||
activeFiltersCount={
|
activeFiltersCount={filters.activeFiltersCount}
|
||||||
isDealer
|
|
||||||
? // For dealers: only count search and status (closure type)
|
|
||||||
[filters.searchTerm, filters.statusFilter !== 'all' ? filters.statusFilter : null].filter(Boolean).length
|
|
||||||
: // For standard users: count all filters
|
|
||||||
filters.activeFiltersCount
|
|
||||||
}
|
|
||||||
onSearchChange={filters.setSearchTerm}
|
onSearchChange={filters.setSearchTerm}
|
||||||
onPriorityChange={filters.setPriorityFilter}
|
onPriorityChange={filters.setPriorityFilter}
|
||||||
onStatusChange={filters.setStatusFilter}
|
onStatusChange={filters.setStatusFilter}
|
||||||
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
|
||||||
onSortByChange={filters.setSortBy}
|
onSortByChange={filters.setSortBy}
|
||||||
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
|
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
onClearFilters={filters.clearFilters}
|
onClearFilters={filters.clearFilters}
|
||||||
|
|||||||
@ -61,32 +61,6 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
|
|||||||
>
|
>
|
||||||
{request.priority}
|
{request.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* Template Type Badge */}
|
|
||||||
{(() => {
|
|
||||||
const templateType = request.templateType || '';
|
|
||||||
const templateTypeUpper = templateType?.toUpperCase() || '';
|
|
||||||
|
|
||||||
// Direct mapping from templateType
|
|
||||||
let templateLabel = 'Non-Templatized';
|
|
||||||
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
|
|
||||||
|
|
||||||
if (templateTypeUpper === 'DEALER CLAIM') {
|
|
||||||
templateLabel = 'Dealer Claim';
|
|
||||||
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
|
|
||||||
} else if (templateTypeUpper === 'TEMPLATE') {
|
|
||||||
templateLabel = 'Template';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`${templateColor} text-xs px-2.5 py-0.5 shrink-0 hidden md:inline-flex`}
|
|
||||||
data-testid="template-type-badge"
|
|
||||||
>
|
|
||||||
{templateLabel}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
|
|||||||
@ -12,14 +12,12 @@ interface ClosedRequestsFiltersProps {
|
|||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
priorityFilter: string;
|
priorityFilter: string;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
templateTypeFilter: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
sortBy: 'created' | 'due' | 'priority';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
activeFiltersCount: number;
|
activeFiltersCount: number;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onPriorityChange: (value: string) => void;
|
onPriorityChange: (value: string) => void;
|
||||||
onStatusChange: (value: string) => void;
|
onStatusChange: (value: string) => void;
|
||||||
onTemplateTypeChange: (value: string) => void;
|
|
||||||
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
|
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
|
||||||
onSortOrderChange: () => void;
|
onSortOrderChange: () => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
@ -29,14 +27,12 @@ export function ClosedRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
// templateTypeFilter,
|
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
// onTemplateTypeChange,
|
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -129,17 +125,6 @@ export function ClosedRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select> */}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fetchRequests = useCallback(
|
const fetchRequests = useCallback(
|
||||||
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
|
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -51,7 +51,6 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
|
|||||||
search: filters?.search,
|
search: filters?.search,
|
||||||
status: filters?.status && filters.status !== 'all' ? filters.status : undefined,
|
status: filters?.status && filters.status !== 'all' ? filters.status : undefined,
|
||||||
priority: filters?.priority,
|
priority: filters?.priority,
|
||||||
templateType: filters?.templateType,
|
|
||||||
sortBy: filters?.sortBy,
|
sortBy: filters?.sortBy,
|
||||||
sortOrder: filters?.sortOrder
|
sortOrder: filters?.sortOrder
|
||||||
});
|
});
|
||||||
@ -91,7 +90,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
|
|||||||
// Initial fetch removed - component handles initial fetch using Redux stored page
|
// Initial fetch removed - component handles initial fetch using Redux stored page
|
||||||
// This prevents duplicate fetches and allows page persistence
|
// This prevents duplicate fetches and allows page persistence
|
||||||
|
|
||||||
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
|
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRequests(pagination.currentPage, filters);
|
fetchRequests(pagination.currentPage, filters);
|
||||||
}, [fetchRequests, pagination.currentPage]);
|
}, [fetchRequests, pagination.currentPage]);
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
setSearchTerm as setSearchTermAction,
|
setSearchTerm as setSearchTermAction,
|
||||||
setStatusFilter as setStatusFilterAction,
|
setStatusFilter as setStatusFilterAction,
|
||||||
setPriorityFilter as setPriorityFilterAction,
|
setPriorityFilter as setPriorityFilterAction,
|
||||||
setTemplateTypeFilter as setTemplateTypeFilterAction,
|
|
||||||
setSortBy as setSortByAction,
|
setSortBy as setSortByAction,
|
||||||
setSortOrder as setSortOrderAction,
|
setSortOrder as setSortOrderAction,
|
||||||
setCurrentPage as setCurrentPageAction,
|
setCurrentPage as setCurrentPageAction,
|
||||||
@ -27,13 +26,12 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
// Get filters from Redux
|
// Get filters from Redux
|
||||||
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
|
const { searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
|
||||||
|
|
||||||
// Create setters that dispatch Redux actions
|
// Create setters that dispatch Redux actions
|
||||||
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
||||||
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
|
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
|
||||||
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
||||||
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
|
|
||||||
const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]);
|
const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]);
|
||||||
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
|
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
|
||||||
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||||
@ -43,11 +41,10 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
search: searchTerm,
|
search: searchTerm,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
priority: priorityFilter,
|
priority: priorityFilter,
|
||||||
templateType: templateTypeFilter !== 'all' ? templateTypeFilter : undefined,
|
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
};
|
};
|
||||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder]);
|
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder]);
|
||||||
|
|
||||||
// Debounced filter change handler
|
// Debounced filter change handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -74,7 +71,7 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
clearTimeout(debounceTimeoutRef.current);
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
|
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
dispatch(clearFiltersAction());
|
dispatch(clearFiltersAction());
|
||||||
@ -83,22 +80,19 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
const activeFiltersCount = [
|
const activeFiltersCount = [
|
||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter !== 'all' ? priorityFilter : null,
|
priorityFilter !== 'all' ? priorityFilter : null,
|
||||||
statusFilter !== 'all' ? statusFilter : null,
|
statusFilter !== 'all' ? statusFilter : null
|
||||||
templateTypeFilter !== 'all' ? templateTypeFilter : null
|
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
templateTypeFilter,
|
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
currentPage,
|
currentPage,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setTemplateTypeFilter,
|
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
@ -4,7 +4,6 @@ export interface ClosedRequestsFiltersState {
|
|||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
priorityFilter: string;
|
priorityFilter: string;
|
||||||
templateTypeFilter: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
sortBy: 'created' | 'due' | 'priority';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
@ -14,7 +13,6 @@ const initialState: ClosedRequestsFiltersState = {
|
|||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
statusFilter: 'all',
|
statusFilter: 'all',
|
||||||
priorityFilter: 'all',
|
priorityFilter: 'all',
|
||||||
templateTypeFilter: 'all',
|
|
||||||
sortBy: 'created',
|
sortBy: 'created',
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
@ -33,9 +31,6 @@ const closedRequestsSlice = createSlice({
|
|||||||
setPriorityFilter: (state, action: PayloadAction<string>) => {
|
setPriorityFilter: (state, action: PayloadAction<string>) => {
|
||||||
state.priorityFilter = action.payload;
|
state.priorityFilter = action.payload;
|
||||||
},
|
},
|
||||||
setTemplateTypeFilter: (state, action: PayloadAction<string>) => {
|
|
||||||
state.templateTypeFilter = action.payload;
|
|
||||||
},
|
|
||||||
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => {
|
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => {
|
||||||
state.sortBy = action.payload;
|
state.sortBy = action.payload;
|
||||||
},
|
},
|
||||||
@ -49,7 +44,6 @@ const closedRequestsSlice = createSlice({
|
|||||||
state.searchTerm = '';
|
state.searchTerm = '';
|
||||||
state.statusFilter = 'all';
|
state.statusFilter = 'all';
|
||||||
state.priorityFilter = 'all';
|
state.priorityFilter = 'all';
|
||||||
state.templateTypeFilter = 'all';
|
|
||||||
state.currentPage = 1;
|
state.currentPage = 1;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -59,7 +53,6 @@ export const {
|
|||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setTemplateTypeFilter,
|
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
@ -17,7 +17,6 @@ export interface ClosedRequest {
|
|||||||
department?: string;
|
department?: string;
|
||||||
totalLevels?: number;
|
totalLevels?: number;
|
||||||
completedLevels?: number;
|
completedLevels?: number;
|
||||||
templateType?: string; // Template type for badge display
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClosedRequestsProps {
|
export interface ClosedRequestsProps {
|
||||||
@ -28,7 +27,6 @@ export interface ClosedRequestsFilters {
|
|||||||
search: string;
|
search: string;
|
||||||
status: string;
|
status: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
templateType?: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
sortBy: 'created' | 'due' | 'priority';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,6 @@ export function transformClosedRequest(r: any): ClosedRequest {
|
|||||||
department: r.department,
|
department: r.department,
|
||||||
totalLevels: r.totalLevels || 0,
|
totalLevels: r.totalLevels || 0,
|
||||||
completedLevels: r.summary?.approvedLevels || 0,
|
completedLevels: r.summary?.approvedLevels || 0,
|
||||||
templateType: r.templateType || r.template_type, // Template type for badge display
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { FileText, AlertCircle } from 'lucide-react';
|
import { FileText, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||||
import { sanitizeHTML } from '@/utils/sanitizer';
|
|
||||||
|
|
||||||
interface AdminRequestReviewStepProps {
|
interface AdminRequestReviewStepProps {
|
||||||
template: RequestTemplate;
|
template: RequestTemplate;
|
||||||
@ -48,7 +47,7 @@ export function AdminRequestReviewStep({
|
|||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span>
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span>
|
||||||
<div
|
<div
|
||||||
className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none"
|
className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeHTML(formData.description) }}
|
dangerouslySetInnerHTML={{ __html: formData.description }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -132,7 +132,6 @@ export function CreateRequest({
|
|||||||
documentErrorModal,
|
documentErrorModal,
|
||||||
openValidationModal,
|
openValidationModal,
|
||||||
closeValidationModal,
|
closeValidationModal,
|
||||||
openPolicyViolationModal,
|
|
||||||
closePolicyViolationModal,
|
closePolicyViolationModal,
|
||||||
openDocumentErrorModal,
|
openDocumentErrorModal,
|
||||||
closeDocumentErrorModal,
|
closeDocumentErrorModal,
|
||||||
@ -173,8 +172,6 @@ export function CreateRequest({
|
|||||||
wizardPrevStep,
|
wizardPrevStep,
|
||||||
user: user!,
|
user: user!,
|
||||||
openValidationModal,
|
openValidationModal,
|
||||||
systemPolicy,
|
|
||||||
onPolicyViolation: openPolicyViolationModal,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
goToStep,
|
goToStep,
|
||||||
});
|
});
|
||||||
@ -275,7 +272,6 @@ export function CreateRequest({
|
|||||||
<ApprovalWorkflowStep
|
<ApprovalWorkflowStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
systemPolicy={systemPolicy}
|
|
||||||
onValidationError={(error) =>
|
onValidationError={(error) =>
|
||||||
openValidationModal(
|
openValidationModal(
|
||||||
error.type as 'error' | 'self-assign' | 'not-found',
|
error.type as 'error' | 'self-assign' | 'not-found',
|
||||||
@ -283,7 +279,6 @@ export function CreateRequest({
|
|||||||
error.message
|
error.message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onPolicyViolation={openPolicyViolationModal}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user