Compare commits

..

No commits in common. "main" and "dev_branch" have entirely different histories.

331 changed files with 12712 additions and 50246 deletions

View File

@ -1,5 +0,0 @@
VITE_PUBLIC_VAPID_KEY={{TAKE_IT_FROM_BACKEND_ENV}}
VITE_BASE_URL={{BACKEND_BASE_URL}}
VITE_API_BASE_URL={{BACKEND_BASEURL+api/v1}}
VITE_OKTA_CLIENT_ID={{Client_id_given_by client for respective mode (UAT/DEVELOPMENT))
VITE_OKTA_DOMAIN={{OKTA_DOMAIN}}

3
Attributions.md Normal file
View File

@ -0,0 +1,3 @@
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).

View File

@ -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.

258
DetailedReports_Analysis.md Normal file
View File

@ -0,0 +1,258 @@
# Detailed Reports Page - Data Availability Analysis
## Overview
This document analyzes what data is currently available in the backend and what information is missing for implementing the DetailedReports page.
---
## 1. Request Lifecycle Report
### ✅ **Available Data:**
- **Request Basic Info:**
- `requestNumber` (RE-REQ-2024-XXX)
- `title`
- `priority` (STANDARD/EXPRESS)
- `status` (DRAFT, PENDING, IN_PROGRESS, APPROVED, REJECTED, CLOSED)
- `initiatorId` → Can get initiator name via User model
- `submissionDate`
- `closureDate`
- `createdAt`
- **Current Stage Info:**
- `currentLevel` (1-N)
- `totalLevels`
- Can get current approver from `approval_levels` table
- **TAT Information:**
- `totalTatHours` (cumulative TAT)
- Can calculate overall TAT from `submissionDate` to `closureDate` or `updatedAt`
- Can get level-wise TAT from `approval_levels.tat_hours`
- Can get TAT compliance from `tat_alerts` table
- **From Existing Services:**
- `getCriticalRequests()` - Returns requests with breach info
- `getUpcomingDeadlines()` - Returns active level info
- `getRecentActivity()` - Returns activity feed
### ❌ **Missing Data:**
1. **Current Stage Name/Description:**
- Need to join with `approval_levels` to get `level_name` for current level
- Currently only have `currentLevel` number
2. **Overall TAT Calculation:**
- Need API endpoint that calculates total time from submission to current/closure
- Currently have `totalTatHours` but need actual elapsed time
3. **TAT Compliance Status:**
- Need to determine if "On Time" or "Delayed" based on TAT vs actual time
- Can calculate from `tat_alerts.is_breached` but need endpoint
4. **Timeline/History:**
- Need endpoint to get all approval levels with their start/end times
- Need to show progression through levels
### 🔧 **What Needs to be Built:**
- **New API Endpoint:** `/dashboard/reports/lifecycle`
- Returns requests with:
- Full lifecycle timeline (all levels with dates)
- Overall TAT calculation
- TAT compliance status (On Time/Delayed)
- Current stage name
- All approvers in sequence
---
## 2. User Activity Log Report
### ✅ **Available Data:**
- **Activity Model Fields:**
- `activityId`
- `requestId`
- `userId` → Can get user name from User model
- `userName` (stored directly)
- `activityType` (created, assignment, approval, rejection, etc.)
- `activityDescription` (details of action)
- `ipAddress` (available in model, but may not be logged)
- `createdAt` (timestamp)
- `metadata` (JSONB - can store additional info)
- **From Existing Services:**
- `getRecentActivity()` - Already returns activity feed with pagination
- Returns: `activityId`, `requestId`, `requestNumber`, `requestTitle`, `type`, `action`, `details`, `userId`, `userName`, `timestamp`, `priority`
### ❌ **Missing Data:**
1. **IP Address:**
- Field exists in model but may not be populated
- Need to ensure IP is captured when logging activities
2. **User Agent/Device Info:**
- Field exists (`userAgent`) but may not be populated
- Need to capture browser/device info
3. **Login Activities:**
- Current activity model is request-focused
- Need separate user session/login tracking
- Can check `users.last_login` but need detailed login history
4. **Action Categorization:**
- Need to map `activityType` to display labels:
- "created" → "Created Request"
- "approval" → "Approved Request"
- "rejection" → "Rejected Request"
- "comment" → "Added Comment"
- etc.
5. **Request ID Display:**
- Need to show request number when available
- Currently `getRecentActivity()` returns `requestNumber`
### 🔧 **What Needs to be Built:**
- **Enhance Activity Logging:**
- Capture IP address in activity service
- Capture user agent in activity service
- Add login activity tracking (separate from request activities)
- **New/Enhanced API Endpoint:** `/dashboard/reports/activity-log`
- Filter by date range
- Filter by user
- Filter by action type
- Include IP address and user agent
- Better categorization of actions
---
## 3. Workflow Aging Report
### ✅ **Available Data:**
- **Request Basic Info:**
- `requestNumber`
- `title`
- `initiatorId` → Can get initiator name
- `priority`
- `status`
- `createdAt` (can calculate days open)
- `submissionDate`
- **Current Stage Info:**
- `currentLevel`
- `totalLevels`
- Can get current approver from `approval_levels`
- **From Existing Services:**
- `getUpcomingDeadlines()` - Returns active requests with TAT info
- Can filter by days open using `createdAt` or `submissionDate`
### ❌ **Missing Data:**
1. **Days Open Calculation:**
- Need to calculate from `submissionDate` (not `createdAt`)
- Need to exclude weekends/holidays for accurate business days
2. **Start Date:**
- Should use `submissionDate` (when request was submitted, not created)
- Currently have this field ✅
3. **Assigned To:**
- Need current approver from `approval_levels` where `level_number = current_level`
- Can get from `approval_levels.approver_name`
4. **Current Stage Name:**
- Need `approval_levels.level_name` for current level
- Currently only have level number
5. **Aging Threshold Filtering:**
- Need to filter requests where days open > threshold
- Need to calculate business days (excluding weekends/holidays)
### 🔧 **What Needs to be Built:**
- **New API Endpoint:** `/dashboard/reports/workflow-aging`
- Parameters:
- `threshold` (days)
- `dateRange` (optional)
- `page`, `limit` (pagination)
- Returns:
- Requests with days open > threshold
- Business days calculation
- Current stage name
- Current approver
- Days open (business days)
---
## Summary
### ✅ **Can Show Immediately:**
1. **Request Lifecycle Report (Partial):**
- Request ID, Title, Priority, Status
- Initiator name
- Submission date
- Current level number
- Basic TAT info
2. **User Activity Log (Partial):**
- Timestamp, User, Action, Details
- Request ID (when applicable)
- Using existing `getRecentActivity()` service
3. **Workflow Aging (Partial):**
- Request ID, Title, Initiator
- Days open (calendar days)
- Priority, Status
- Current approver (with join)
### ❌ **Missing/Incomplete:**
1. **Request Lifecycle:**
- Full timeline/history of all levels
- Current stage name (not just number)
- Overall TAT calculation
- TAT compliance status (On Time/Delayed)
2. **User Activity Log:**
- IP Address (field exists but may not be populated)
- User Agent (field exists but may not be populated)
- Login activities (separate tracking needed)
- Better action categorization
3. **Workflow Aging:**
- Business days calculation (excluding weekends/holidays)
- Current stage name
- Proper threshold filtering
### 🔧 **Required Backend Work:**
1. **New Endpoints:**
- `/dashboard/reports/lifecycle` - Full lifecycle with timeline
- `/dashboard/reports/activity-log` - Enhanced activity log with filters
- `/dashboard/reports/workflow-aging` - Aging report with business days
2. **Enhancements:**
- Capture IP address in activity logging
- Capture user agent in activity logging
- Add login activity tracking
- Add business days calculation utility
- Add level name to approval levels response
3. **Data Joins:**
- Join `approval_levels` to get current stage name
- Join `users` to get approver names
- Join `tat_alerts` to get breach/compliance info
---
## Recommendations
### Phase 1 (Quick Win - Use Existing Data):
- Implement basic reports using existing services
- Show available data (request info, basic activity, calendar days)
- Add placeholders for missing data
### Phase 2 (Backend Development):
- Build new report endpoints
- Enhance activity logging to capture IP/user agent
- Add business days calculation
- Add level name to responses
### Phase 3 (Full Implementation):
- Complete all three reports with full data
- Add filtering, sorting, export functionality
- Add date range filters
- Add user/role-based filtering

View File

@ -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.

View File

@ -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!

View File

@ -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.

View File

@ -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.

623
README.md
View File

@ -4,92 +4,43 @@ A modern, enterprise-grade approval and request management system built with Rea
## 📋 Table of Contents
- [Features](#-features)
- [Tech Stack](#-tech-stack)
- [Prerequisites](#-prerequisites)
- [Installation](#-installation)
- [Development](#-development)
- [Project Structure](#-project-structure)
- [Available Scripts](#-available-scripts)
- [Configuration](#-configuration)
- [Key Features Deep Dive](#-key-features-deep-dive)
- [Troubleshooting](#-troubleshooting)
- [Contributing](#-contributing)
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Development](#development)
- [Project Structure](#project-structure)
- [Available Scripts](#available-scripts)
- [Configuration](#configuration)
- [Contributing](#contributing)
## ✨ Features
### 🔄 Dual Workflow System
- **Custom Request Workflow** - User-defined approvers, spectators, and workflow steps
- **Claim Management Workflow** - 8-step predefined process for dealer claim management
- Flexible approval chains with multi-level approvers
- TAT (Turnaround Time) tracking at each approval level
- **🔄 Dual Workflow System**
- Custom Request Workflow with user-defined approvers
- Claim Management Workflow (8-step predefined process)
### 📊 Comprehensive Dashboard
- Real-time statistics and metrics
- High-priority alerts and critical request tracking
- Recent activity feed with pagination
- Upcoming deadlines and SLA breach warnings
- Department-wise performance metrics
- Customizable KPI widgets (Admin only)
- **📊 Comprehensive Dashboard**
- Real-time statistics and metrics
- High-priority alerts
- Recent activity tracking
### 🎯 Request Management
- Create, track, and manage approval requests
- Document upload and management with file type validation
- Work notes and comprehensive audit trails
- Spectator and stakeholder management
- Request filtering, search, and export capabilities
- Detailed request lifecycle tracking
- **🎯 Request Management**
- Create, track, and manage approval requests
- Document upload and management
- Work notes and audit trails
- Spectator and stakeholder management
### 👥 Admin Control Panel
- **User Management** - Search, assign roles (USER, MANAGEMENT, ADMIN), and manage user permissions
- **User Role Management** - Assign and manage user roles with Okta integration
- **System Configuration** - Comprehensive admin settings:
- **KPI Configuration** - Configure dashboard KPIs, visibility, and thresholds
- **Analytics Configuration** - Data retention, export formats, and analytics features
- **TAT Configuration** - Working hours, priority-based TAT, escalation settings
- **Notification Configuration** - Email templates, notification channels, and settings
- **Notification Preferences** - User-configurable notification settings
- **Document Configuration** - File type restrictions, size limits, upload policies
- **Dashboard Configuration** - Customize dashboard layout and widgets per role
- **AI Configuration** - AI provider settings, parameters, and features
- **Sharing Configuration** - Sharing policies and permissions
- **Holiday Management** - Configure business holidays for SLA calculations
- **🎨 Modern UI/UX**
- Responsive design (mobile, tablet, desktop)
- Dark mode support
- Accessible components (WCAG compliant)
- Royal Enfield brand theming
### 📈 Approver Performance Analytics
- Detailed approver performance metrics and statistics
- Request approval history and trends
- Average approval time analysis
- Approval rate and efficiency metrics
- TAT compliance tracking per approver
- Performance comparison and benchmarking
- Export capabilities for performance reports
### 💬 Real-Time Live Chat (Work Notes)
- **WebSocket Integration** - Real-time bidirectional communication
- **Live Work Notes** - Instant messaging within request context
- **Presence Indicators** - See who's online/offline in real-time
- **Mention System** - @mention participants for notifications
- **File Attachments** - Share documents directly in chat
- **Message History** - Persistent chat history per request
- **Auto-reconnection** - Automatic reconnection on network issues
- **Room-based Communication** - Isolated chat rooms per request
### 🔔 Advanced Notifications
- **Web Push Notifications** - Browser push notifications using VAPID
- **Service Worker Integration** - Background notification delivery
- **Real-time Toast Notifications** - In-app notification system
- **SLA Tracking & Reminders** - Automated TAT breach alerts
- **Approval Status Updates** - Real-time status change notifications
- **Email Notifications** - Configurable email notification channels
- **Notification Preferences** - User-configurable notification settings
### 🎨 Modern UI/UX
- Responsive design (mobile, tablet, desktop)
- Dark mode support
- Accessible components (WCAG compliant)
- Royal Enfield brand theming
- Smooth animations and transitions
- Intuitive navigation and user flows
- **🔔 Notifications**
- Real-time toast notifications
- SLA tracking and reminders
- Approval status updates
## 🛠️ Tech Stack
@ -99,11 +50,8 @@ A modern, enterprise-grade approval and request management system built with Rea
- **Styling:** Tailwind CSS 3.4+
- **UI Components:** shadcn/ui + Radix UI
- **Icons:** Lucide React
- **Notifications:** Sonner (Toast) + Web Push API (VAPID)
- **Real-Time Communication:** Socket.IO Client
- **State Management:** React Hooks (useState, useMemo, useContext)
- **Authentication:** Okta SSO Integration
- **HTTP Client:** Axios
- **Notifications:** Sonner
- **State Management:** React Hooks (useState, useMemo)
## 📦 Prerequisites
@ -113,20 +61,11 @@ A modern, enterprise-grade approval and request management system built with Rea
## 🚀 Installation
### Quick Start Checklist
- [ ] Clone the repository
- [ ] Install Node.js (>= 18.0.0) and npm (>= 9.0.0)
- [ ] Install project dependencies
- [ ] Set up environment variables (`.env.local`)
- [ ] Ensure backend API is running (optional for initial setup)
- [ ] Start development server
### 1. Clone the repository
\`\`\`bash
git clone <repository-url>
cd Re_Frontend_Code
cd Re_Figma_Code
\`\`\`
### 2. Install dependencies
@ -137,123 +76,36 @@ npm install
### 3. Set up environment variables
#### Option A: Automated Setup (Recommended - Unix/Linux/Mac)
Run the setup script to automatically create environment files:
\`\`\`bash
chmod +x setup-env.sh
./setup-env.sh
cp .env.example .env
\`\`\`
This script will:
- Create `.env.example` with all required variables
- Create `.env.local` for local development
- Create `.env.production` with your production configuration (interactive)
#### Option B: Manual Setup (Windows or Custom Configuration)
**For Windows (PowerShell):**
1. Create `.env.local` file in the project root:
\`\`\`powershell
# Create .env.local file
New-Item -Path .env.local -ItemType File
\`\`\`
2. Add the following content to `.env.local`:
Edit `.env` with your configuration:
\`\`\`env
# Local Development Environment
VITE_API_BASE_URL=http://localhost:5000/api/v1
VITE_BASE_URL=http://localhost:5000
# Okta Authentication Configuration
VITE_OKTA_DOMAIN=your-okta-domain.okta.com
VITE_OKTA_CLIENT_ID=your-okta-client-id
# Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=your-vapid-public-key
VITE_API_BASE_URL=http://localhost:5000/api
VITE_APP_NAME=Royal Enfield Approval Portal
\`\`\`
**For Production:**
Create `.env.production` with production values:
\`\`\`env
# Production Environment
VITE_API_BASE_URL=https://your-backend-url.com/api/v1
VITE_BASE_URL=https://your-backend-url.com
# Okta Authentication Configuration
VITE_OKTA_DOMAIN=https://your-org.okta.com
VITE_OKTA_CLIENT_ID=your-production-client-id
# Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=your-production-vapid-key
\`\`\`
#### Environment Variables Reference
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
| `VITE_API_BASE_URL` | Backend API base URL (with `/api/v1`) | Yes | `http://localhost:5000/api/v1` |
| `VITE_BASE_URL` | Base URL for WebSocket and direct file access (without `/api/v1`) | Yes | `http://localhost:5000` |
| `VITE_OKTA_DOMAIN` | Okta domain for SSO authentication | Yes* | - |
| `VITE_OKTA_CLIENT_ID` | Okta client ID for authentication | Yes* | - |
| `VITE_PUBLIC_VAPID_KEY` | Public VAPID key for web push notifications | No | - |
**Notes:**
- `VITE_BASE_URL` is used for WebSocket connections and must point to the base backend URL (not `/api/v1`)
- `VITE_PUBLIC_VAPID_KEY` is required for web push notifications. Generate using:
\`\`\`bash
npm install -g web-push
web-push generate-vapid-keys
\`\`\`
Use the **public key** in the frontend `.env.local` file
\*Required if using Okta authentication
### 4. Verify setup
Check that all required files exist:
### 4. Move files to src directory
\`\`\`bash
# Check environment file exists
ls -la .env.local # Unix/Linux/Mac
# or
Test-Path .env.local # Windows PowerShell
# Create src directory structure
mkdir -p src/components src/utils src/styles src/types
# Move existing files (you'll need to do this manually or run the migration script)
# The structure should match the project structure below
\`\`\`
## 💻 Development
### Prerequisites
Before starting development, ensure:
1. **Backend API is running:**
- The backend should be running on `http://localhost:5000` (or your configured URL)
- Backend API should be accessible at `/api/v1` endpoint
- CORS should be configured to allow your frontend origin
2. **Environment variables are configured:**
- `.env.local` file exists and contains valid configuration
- All required variables are set (see [Environment Variables Reference](#environment-variables-reference))
3. **Node.js and npm versions:**
- Verify Node.js version: `node --version` (should be >= 18.0.0)
- Verify npm version: `npm --version` (should be >= 9.0.0)
### Start development server
\`\`\`bash
npm run dev
\`\`\`
The application will open at `http://localhost:5173` (Vite default port)
> **Note:** If port 5173 is in use, Vite will automatically use the next available port.
The application will open at `http://localhost:3000`
### Build for production
@ -274,57 +126,28 @@ Re_Figma_Code/
├── src/
│ ├── components/
│ │ ├── ui/ # Reusable UI components (40+)
│ │ ├── admin/ # Admin components
│ │ │ ├── AIConfig/ # AI configuration
│ │ │ ├── AnalyticsConfig/ # Analytics settings
│ │ │ ├── DashboardConfig/ # Dashboard customization
│ │ │ ├── DocumentConfig/ # Document policies
│ │ │ ├── NotificationConfig/ # Notification settings
│ │ │ ├── SharingConfig/ # Sharing policies
│ │ │ ├── TATConfig/ # TAT configuration
│ │ │ ├── UserManagement/ # User management
│ │ │ └── UserRoleManager/ # Role assignment
│ │ ├── approval/ # Approval workflow components
│ │ ├── common/ # Common reusable components
│ │ ├── dashboard/ # Dashboard widgets
│ │ ├── modals/ # Modal components
│ │ ├── participant/ # Participant management
│ │ ├── workflow/ # Workflow components
│ │ └── workNote/ # Work notes/chat components
│ ├── pages/
│ │ ├── Admin/ # Admin control panel
│ │ ├── ApproverPerformance/ # Approver analytics
│ │ ├── Auth/ # Authentication pages
│ │ ├── Dashboard/ # Main dashboard
│ │ ├── RequestDetail/ # Request detail view
│ │ ├── Requests/ # Request listing
│ │ └── ...
│ ├── hooks/ # Custom React hooks
│ │ ├── useRequestSocket.ts # WebSocket integration
│ │ ├── useDocumentUpload.ts # Document management
│ │ ├── useSLATracking.ts # SLA tracking
│ │ └── ...
│ ├── services/ # API services
│ │ ├── adminApi.ts # Admin API calls
│ │ ├── authApi.ts # Authentication API
│ │ ├── workflowApi.ts # Workflow API
│ │ ├── figma/ # Figma-specific components
│ │ ├── Dashboard.tsx
│ │ ├── Layout.tsx
│ │ ├── ClaimManagementWizard.tsx
│ │ ├── NewRequestWizard.tsx
│ │ ├── RequestDetail.tsx
│ │ ├── ClaimManagementDetail.tsx
│ │ ├── MyRequests.tsx
│ │ └── ...
│ ├── utils/
│ │ ├── socket.ts # Socket.IO utilities
│ │ ├── pushNotifications.ts # Web push notifications
│ │ ├── slaTracker.ts # SLA calculation utilities
│ │ └── ...
│ ├── contexts/
│ │ └── AuthContext.tsx # Authentication context
│ │ ├── customRequestDatabase.ts
│ │ ├── claimManagementDatabase.ts
│ │ └── dealerDatabase.ts
│ ├── styles/
│ │ └── globals.css
│ ├── types/
│ │ └── index.ts
│ │ └── index.ts # TypeScript type definitions
│ ├── App.tsx
│ └── main.tsx
├── public/
│ └── service-worker.js # Service worker for push notifications
├── .vscode/
├── public/ # Static assets
├── .vscode/ # VS Code settings
├── index.html
├── vite.config.ts
├── tsconfig.json
@ -357,13 +180,9 @@ The project uses path aliases for cleaner imports:
\`\`\`typescript
import { Button } from '@/components/ui/button';
import { getSocket } from '@/utils/socket';
import { getDealerInfo } from '@/utils/dealerDatabase';
\`\`\`
Path aliases are configured in:
- `tsconfig.json` - TypeScript path mapping
- `vite.config.ts` - Vite resolver configuration
### Tailwind CSS Customization
Custom Royal Enfield colors are defined in `tailwind.config.ts`:
@ -382,310 +201,66 @@ colors: {
All environment variables must be prefixed with `VITE_` to be accessible in the app:
\`\`\`typescript
// Access environment variables
const apiUrl = import.meta.env.VITE_API_BASE_URL;
const baseUrl = import.meta.env.VITE_BASE_URL;
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN;
\`\`\`
**Important Notes:**
- Environment variables are embedded at build time, not runtime
- Changes to `.env` files require restarting the dev server
- `.env.local` takes precedence over `.env` in development
- `.env.production` is used when building for production (`npm run build`)
## 🔧 Next Steps
### Backend Integration
### 1. File Migration
To connect to the backend API:
1. **Update API base URL** in `.env.local`:
\`\`\`env
VITE_API_BASE_URL=http://localhost:5000/api/v1
VITE_BASE_URL=http://localhost:5000
\`\`\`
2. **Configure CORS** in your backend to allow your frontend origin
3. **Authentication:**
- Configure Okta credentials in environment variables
- Ensure backend validates JWT tokens from Okta
- Backend should handle token exchange and refresh
4. **WebSocket Configuration:**
- Backend must support Socket.IO on the base URL
- Socket.IO path: `/socket.io`
- CORS must allow WebSocket connections
- Events: `worknote:new`, `presence:join`, `presence:leave`, `request:online-users`
5. **Web Push Notifications:**
- Backend must support VAPID push notification delivery
- Service worker registration endpoint required
- Push subscription management API needed
- Generate VAPID keys using: `npm install -g web-push && web-push generate-vapid-keys`
6. **API Services:**
- API services are located in `src/services/`
- All API calls use `axios` configured with base URL from environment
- WebSocket utilities in `src/utils/socket.ts`
### Development vs Production
- **Development:** Uses `.env.local` (git-ignored)
- **Production:** Uses `.env.production` or environment variables set in deployment platform
- **Never commit:** `.env.local` or `.env.production` (use `.env.example` as template)
## 🚀 Key Features Deep Dive
### 👥 Admin Control Panel
The Admin Control Panel (`/admin`) provides comprehensive system management capabilities accessible only to ADMIN role users:
#### User Management
- **User Search**: Search users via Okta integration with real-time search
- **Role Assignment**: Assign and manage user roles (USER, MANAGEMENT, ADMIN)
- **User Statistics**: View role distribution and user counts
- **Pagination**: Efficient pagination for large user lists
- **Filtering**: Filter users by role (ELEVATED, ADMIN, MANAGEMENT, USER, ALL)
#### System Configuration Tabs
1. **KPI Configuration**
- Configure dashboard KPIs with visibility settings per role
- Set alert thresholds and breach conditions
- Organize KPIs by categories (Request Volume, TAT Efficiency, Approver Load, etc.)
- Enable/disable KPIs dynamically
2. **Analytics Configuration**
- Data retention policies
- Export format settings (CSV, Excel, PDF)
- Analytics feature toggles
- Data collection preferences
3. **TAT Configuration**
- Working hours configuration (start/end time, days of week)
- Priority-based TAT settings (express, standard, urgent)
- Escalation rules and thresholds
- Holiday calendar integration
4. **Notification Configuration**
- Email template customization
- Notification channel management (email, push, in-app)
- Notification frequency settings
- Delivery preferences
- **Notification Preferences** - User-configurable notification settings
5. **Document Configuration**
- Allowed file types and extensions
- File size limits (per file and total)
- Upload validation rules
- Document policy enforcement
6. **Dashboard Configuration**
- Customize dashboard layout per role
- Widget visibility settings
- Dashboard widget ordering
- Role-specific dashboard views
7. **AI Configuration**
- AI provider settings (OpenAI, Anthropic, etc.)
- AI parameters and model selection
- AI feature toggles
- API key management
8. **Sharing Configuration**
- Sharing policies and permissions
- External sharing settings
- Access control rules
#### Holiday Management
- Configure business holidays for accurate SLA calculations
- Holiday calendar integration
- Regional holiday support
### 📈 Approver Performance Dashboard
Comprehensive analytics dashboard (`/approver-performance`) for tracking and analyzing approver performance:
#### Key Metrics
- **Approval Statistics**: Total approvals, approval rate, average approval time
- **TAT Compliance**: Percentage of approvals within TAT
- **Request History**: Complete list of requests handled by approver
- **Performance Trends**: Time-based performance analysis
- **SLA Metrics**: TAT breach analysis and compliance tracking
#### Features
- **Advanced Filtering**: Filter by date range, status, priority, SLA compliance
- **Search Functionality**: Search requests by title, ID, or description
- **Export Capabilities**: Export performance data in CSV/Excel formats
- **Visual Analytics**: Charts and graphs for performance visualization
- **Comparison Tools**: Compare performance across different time periods
- **Detailed Request List**: View all requests with approval details
### 💬 Real-Time Live Chat (Work Notes)
Powered by Socket.IO for instant, bidirectional communication within request context:
#### Core Features
- **Real-Time Messaging**: Instant message delivery with Socket.IO
- **Presence Indicators**: See who's online/offline in real-time
- **@Mention System**: Mention participants using @username syntax
- **File Attachments**: Upload and share documents directly in chat
- **Message History**: Persistent chat history per request
- **Rich Text Support**: Format messages with mentions and links
#### Technical Implementation
- **Socket.IO Client**: Real-time WebSocket communication
- **Room-Based Architecture**: Each request has isolated chat room
- **Auto-Reconnection**: Automatic reconnection on network issues
- **Presence Management**: Real-time user presence tracking
- **Message Persistence**: Messages stored in backend database
#### Usage
- Access via Request Detail page > Work Notes tab
- Full-screen chat interface available
- Real-time updates for all participants
- Notification on new messages
### 🔔 Web Push Notifications
Browser push notifications using VAPID (Voluntary Application Server Identification) protocol:
#### Features
- **Service Worker Integration**: Background notification delivery
- **VAPID Protocol**: Secure, standards-based push notifications
- **Permission Management**: User-friendly permission requests
- **Notification Preferences**: User-configurable notification settings
- **Cross-Platform Support**: Works on desktop and mobile browsers
- **Offline Queue**: Notifications queued when browser is offline
#### Configuration
1. Generate VAPID keys (public/private pair)
2. Set `VITE_PUBLIC_VAPID_KEY` in environment variables
3. Backend must support VAPID push notification delivery
4. Service worker automatically registers on app load
#### Notification Types
- **SLA Alerts**: TAT breach and approaching deadline notifications
- **Approval Requests**: New requests assigned to approver
- **Status Updates**: Request status change notifications
- **Work Note Mentions**: Notifications when mentioned in chat
- **System Alerts**: Critical system notifications
#### Browser Support
- Chrome/Edge: Full support
- Firefox: Full support
- Safari: Limited support (macOS/iOS)
- Requires HTTPS in production
## 🔧 Troubleshooting
### Common Issues
#### WebSocket Connection Issues
If real-time features (chat, presence) are not working:
1. Verify backend Socket.IO server is running
2. Check `VITE_BASE_URL` in `.env.local` (should not include `/api/v1`)
3. Ensure CORS allows WebSocket connections
4. Check browser console for Socket.IO connection errors
5. Verify Socket.IO path is `/socket.io` (default)
Move existing files to the `src` directory:
\`\`\`bash
# Test WebSocket connection
# Open browser console and check for Socket.IO connection logs
# Move App.tsx
mv App.tsx src/
# Move components
mv components src/
# Move utils
mv utils src/
# Move styles
mv styles src/
\`\`\`
#### Web Push Notifications Not Working
### 2. Create main.tsx entry point
1. Ensure `VITE_PUBLIC_VAPID_KEY` is set in `.env.local`
2. Verify service worker is registered (check Application tab in DevTools)
3. Check browser notification permissions
4. Ensure HTTPS in production (required for push notifications)
5. Verify backend push notification endpoint is configured
Create `src/main.tsx`:
\`\`\`bash
# Check service worker registration
# Open DevTools > Application > Service Workers
\`\`\`typescript
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
\`\`\`
#### Port Already in Use
### 3. Update imports
If the default port (5173) is in use:
Update all import paths to use the `@/` alias:
\`\`\`bash
# Option 1: Kill the process using the port
# Windows
netstat -ano | findstr :5173
taskkill /PID <PID> /F
\`\`\`typescript
// Before
import { Button } from './components/ui/button';
# Unix/Linux/Mac
lsof -ti:5173 | xargs kill -9
# Option 2: Use a different port
npm run dev -- --port 3000
// After
import { Button } from '@/components/ui/button';
\`\`\`
#### Environment Variables Not Loading
### 4. Backend Integration
1. Ensure variables are prefixed with `VITE_`
2. Restart the dev server after changing `.env` files
3. Check that `.env.local` exists in the project root
4. Verify no typos in variable names
When ready to connect to a real API:
#### Backend Connection Issues
1. Verify backend is running on the configured port
2. Check `VITE_API_BASE_URL` in `.env.local` matches backend URL
3. Ensure CORS is configured in backend to allow frontend origin
4. Check browser console for detailed error messages
#### Build Errors
\`\`\`bash
# Clear cache and rebuild
rm -rf node_modules/.vite
npm run build
# Check for TypeScript errors
npm run type-check
\`\`\`
### Getting Help
- Check browser console for errors
- Verify all environment variables are set correctly
- Ensure Node.js and npm versions meet requirements
- Review backend logs for API-related issues
- Check Network tab for WebSocket connection status
- Verify service worker registration in DevTools > Application
## 🔐 Role-Based Access Control
The application supports three user roles with different access levels:
### USER Role
- Create and manage own requests
- View assigned requests
- Approve/reject requests assigned to them
- Add work notes and comments
- Upload documents
### MANAGEMENT Role
- All USER permissions
- View all requests across the organization
- Access to detailed reports and analytics
- Approver Performance dashboard access
- Export capabilities
### ADMIN Role
- All MANAGEMENT permissions
- Access to Admin Control Panel (`/admin`)
- User management and role assignment
- System configuration (TAT, Notifications, Documents, etc.)
- KPI configuration and dashboard customization
- Holiday calendar management
- Full system administration capabilities
1. Create `src/services/api.ts` for API calls
2. Replace mock databases with API calls
3. Add authentication layer
4. Implement error handling
## 🧪 Testing (Future Enhancement)

View File

@ -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!

193
ROLE_MIGRATION.md Normal file
View File

@ -0,0 +1,193 @@
# Frontend Role Migration - isAdmin → role
## 🎯 Overview
Migrated frontend from `isAdmin: boolean` to `role: 'USER' | 'MANAGEMENT' | 'ADMIN'` to match the new backend RBAC system.
---
## ✅ Files Updated
### 1. **Type Definitions**
#### `src/contexts/AuthContext.tsx`
- ✅ Updated `User` interface: `isAdmin?: boolean``role?: 'USER' | 'MANAGEMENT' | 'ADMIN'`
- ✅ Added helper functions:
- `isAdmin(user)` - Checks if user is ADMIN
- `isManagement(user)` - Checks if user is MANAGEMENT
- `hasManagementAccess(user)` - Checks if user is MANAGEMENT or ADMIN
- `hasAdminAccess(user)` - Checks if user is ADMIN (same as isAdmin)
#### `src/services/authApi.ts`
- ✅ Updated `TokenExchangeResponse` interface: `isAdmin: boolean``role: 'USER' | 'MANAGEMENT' | 'ADMIN'`
---
### 2. **Components Updated**
#### `src/pages/Dashboard/Dashboard.tsx`
**Changes:**
- ✅ Imported `isAdmin as checkIsAdmin` from AuthContext
- ✅ Updated role check: `(user as any)?.isAdmin || false``checkIsAdmin(user)`
- ✅ All conditional rendering now uses the helper function
**Admin Features (shown only for ADMIN role):**
- Organization-wide analytics
- Admin View badge
- Export button
- Department-wise workflow summary
- Priority distribution report
- TAT breach report
- AI remark utilization report
- Approver performance report
---
#### `src/pages/Settings/Settings.tsx`
**Changes:**
- ✅ Imported `isAdmin as checkIsAdmin` from AuthContext
- ✅ Updated role check: `(user as any)?.isAdmin``checkIsAdmin(user)`
**Admin Features:**
- Configuration Manager tab
- Holiday Manager tab
- System Settings tab
---
#### `src/pages/Profile/Profile.tsx`
**Changes:**
- ✅ Imported `isAdmin` and `isManagement` helpers from AuthContext
- ✅ Added `Users` icon import for Management badge
- ✅ Updated all `user?.isAdmin` checks to use `isAdmin(user)`
- ✅ Added Management badge display for MANAGEMENT role
- ✅ Updated role display to show:
- **Administrator** badge (yellow) for ADMIN
- **Management** badge (blue) for MANAGEMENT
- **User** badge (gray) for USER
**New Visual Indicators:**
- 🟡 Yellow shield icon for ADMIN users
- 🔵 Blue users icon for MANAGEMENT users
- Role badge on profile card
- Role badge in header section
---
#### `src/pages/Auth/AuthenticatedApp.tsx`
**Changes:**
- ✅ Updated console log: `'Is Admin:', user.isAdmin``'Role:', user.role`
---
## 🎨 **Visual Changes**
### Profile Page Badges
**Before:**
```
🟡 Administrator (only for admins)
```
**After:**
```
🟡 Administrator (for ADMIN)
🔵 Management (for MANAGEMENT)
```
### Role Display
**Before:**
- Administrator / User
**After:**
- Administrator (yellow badge, green checkmark)
- Management (blue badge, green checkmark)
- User (gray badge, no checkmark)
---
## 🔧 **Helper Functions Usage**
### In Components:
```typescript
import { useAuth, isAdmin, isManagement, hasManagementAccess } from '@/contexts/AuthContext';
const { user } = useAuth();
// Check if user is admin
if (isAdmin(user)) {
// Show admin-only features
}
// Check if user is management
if (isManagement(user)) {
// Show management-only features
}
// Check if user has management access (MANAGEMENT or ADMIN)
if (hasManagementAccess(user)) {
// Show features for both management and admin
}
```
---
## 🚀 **Migration Benefits**
1. **Type Safety** - Role is now a union type, catching errors at compile time
2. **Flexibility** - Easy to add more roles (e.g., AUDITOR, VIEWER)
3. **Granular Access** - Can differentiate between MANAGEMENT and ADMIN
4. **Consistency** - Frontend now matches backend RBAC system
5. **Helper Functions** - Cleaner code with reusable role checks
---
## 📊 **Access Levels**
| Feature | USER | MANAGEMENT | ADMIN |
|---------|------|------------|-------|
| View own requests | ✅ | ✅ | ✅ |
| View own dashboard | ✅ | ✅ | ✅ |
| View all requests | ❌ | ✅ | ✅ |
| View organization-wide analytics | ❌ | ✅ | ✅ |
| Export data | ❌ | ❌ | ✅ |
| Manage system configuration | ❌ | ❌ | ✅ |
| Manage holidays | ❌ | ❌ | ✅ |
| View TAT breach reports | ❌ | ❌ | ✅ |
| View approver performance | ❌ | ❌ | ✅ |
---
## ✅ **Testing Checklist**
- [ ] Login as USER - verify limited access
- [ ] Login as MANAGEMENT - verify read access to all data
- [ ] Login as ADMIN - verify full access
- [ ] Profile page shows correct role badge
- [ ] Dashboard shows appropriate views per role
- [ ] Settings page shows tabs only for ADMIN
- [ ] No console errors related to role checks
---
## 🔄 **Backward Compatibility**
**None** - This is a breaking change. All users must be assigned a role in the database:
```sql
-- Default all users to USER role
UPDATE users SET role = 'USER' WHERE role IS NULL;
-- Assign specific roles
UPDATE users SET role = 'ADMIN' WHERE email = 'admin@royalenfield.com';
UPDATE users SET role = 'MANAGEMENT' WHERE email = 'manager@royalenfield.com';
```
---
## 🎉 **Deployment Ready**
All changes are complete and linter-clean. Frontend now fully supports the new RBAC system!

View File

@ -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.

339
USER_ROLE_MANAGEMENT.md Normal file
View File

@ -0,0 +1,339 @@
# User Role Management Feature
## 🎯 Overview
Added a comprehensive User Role Management system for administrators to assign roles to users directly from the Settings page.
---
## ✅ What Was Built
### Frontend Components
#### 1. **UserRoleManager Component**
Location: `src/components/admin/UserRoleManager/UserRoleManager.tsx`
**Features:**
- **Search Users from Okta** - Real-time search with debouncing
- **Role Assignment** - Assign USER, MANAGEMENT, or ADMIN roles
- **Statistics Dashboard** - Shows count of users in each role
- **Elevated Users List** - Displays all ADMIN and MANAGEMENT users
- **Auto-create Users** - If user doesn't exist in database, fetches from Okta and creates them
- **Self-demotion Prevention** - Admin cannot demote themselves
**UI Components:**
- Statistics cards showing admin/management/user counts
- Search input with dropdown results
- Selected user card display
- Role selector dropdown
- Assign button with loading state
- Success/error message display
- Elevated users list with role badges
---
### Backend APIs
#### 2. **New Route: Assign Role by Email**
`POST /api/v1/admin/users/assign-role`
**Purpose:** Assign role to user by email (creates user from Okta if doesn't exist)
**Request:**
```json
{
"email": "user@royalenfield.com",
"role": "MANAGEMENT" // or "USER" or "ADMIN"
}
```
**Response:**
```json
{
"success": true,
"message": "Successfully assigned MANAGEMENT role to John Doe",
"data": {
"userId": "abc-123",
"email": "user@royalenfield.com",
"displayName": "John Doe",
"role": "MANAGEMENT"
}
}
```
**Flow:**
1. Check if user exists in database by email
2. If not exists → Search Okta API
3. If found in Okta → Create user in database with assigned role
4. If exists → Update user's role
5. Prevent self-demotion (admin demoting themselves)
---
#### 3. **Existing Routes (Already Created)**
**Get Users by Role**
```
GET /api/v1/admin/users/by-role?role=ADMIN
GET /api/v1/admin/users/by-role?role=MANAGEMENT
```
**Get Role Statistics**
```
GET /api/v1/admin/users/role-statistics
```
Response:
```json
{
"success": true,
"data": {
"statistics": [
{ "role": "ADMIN", "count": 3 },
{ "role": "MANAGEMENT", "count": 12 },
{ "role": "USER", "count": 145 }
],
"total": 160
}
}
```
**Update User Role by ID**
```
PUT /api/v1/admin/users/:userId/role
Body: { "role": "MANAGEMENT" }
```
---
### Settings Page Updates
#### 4. **New Tab: "User Roles"**
Location: `src/pages/Settings/Settings.tsx`
**Changes:**
- Added 4th tab to admin settings
- Tab layout now responsive: 2 columns on mobile, 4 on desktop
- Tab order: User Settings → **User Roles** → Configuration → Holidays
- Only visible to ADMIN role users
**Tab Structure:**
```
┌─────────────┬────────────┬──────────────┬──────────┐
│ User │ User Roles │ Config │ Holidays │
│ Settings │ (NEW! ✨) │ │ │
└─────────────┴────────────┴──────────────┴──────────┘
```
---
### API Service Updates
#### 5. **User API Service**
Location: `src/services/userApi.ts`
**New Functions:**
```typescript
userApi.assignRole(email, role) // Assign role by email
userApi.updateUserRole(userId, role) // Update role by userId
userApi.getUsersByRole(role) // Get users filtered by role
userApi.getRoleStatistics() // Get role counts
```
---
## 🎨 UI/UX Features
### Statistics Cards
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Administrators │ │ Management │ │ Regular Users │
│ 3 │ │ 12 │ │ 145 │
│ 👑 ADMIN │ │ 👥 MANAGEMENT │ │ 👤 USER │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
### Role Assignment Section
1. **Search Input** - Type name or email
2. **Results Dropdown** - Shows matching Okta users
3. **Selected User Card** - Displays chosen user details
4. **Role Selector** - Dropdown with 3 role options
5. **Assign Button** - Confirms role assignment
### Elevated Users List
- Shows all ADMIN and MANAGEMENT users
- Regular USER role users are not shown (too many)
- Each user card shows:
- Role icon and badge
- Display name
- Email
- Department and designation
---
## 🔐 Access Control
### ADMIN Only
- View User Roles tab
- Search and assign roles
- View all elevated users
- Create users from Okta
- Demote users (except themselves)
### MANAGEMENT & USER
- Cannot access User Roles tab
- See info message about admin features
---
## 🔄 User Creation Flow
### Scenario 1: User Exists in Database
```
1. Admin searches "john@royalenfield.com"
2. Finds user in search results
3. Selects user
4. Assigns MANAGEMENT role
5. ✅ User role updated
```
### Scenario 2: User Doesn't Exist in Database
```
1. Admin searches "new.user@royalenfield.com"
2. Finds user in Okta search results
3. Selects user
4. Assigns MANAGEMENT role
5. Backend fetches full details from Okta
6. Creates user in database with MANAGEMENT role
7. ✅ User created and role assigned
```
### Scenario 3: User Not in Okta
```
1. Admin searches "fake@email.com"
2. No results found
3. If admin types email manually and tries to assign
4. ❌ Error: "User not found in Okta. Please ensure the email is correct."
```
---
## 🎯 Role Badge Colors
| Role | Badge Color | Icon | Access Level |
|------|-------------|------|--------------|
| ADMIN | 🟡 Yellow | 👑 Crown | Full system access |
| MANAGEMENT | 🔵 Blue | 👥 Users | Read all data, enhanced dashboards |
| USER | ⚪ Gray | 👤 User | Own requests and assigned workflows |
---
## 📊 Test Scenarios
### Test 1: Assign MANAGEMENT Role to Existing User
```
1. Login as ADMIN
2. Go to Settings → User Roles tab
3. Search for existing user
4. Select MANAGEMENT role
5. Click Assign Role
6. Verify success message
7. Check user appears in Elevated Users list
```
### Test 2: Create New User from Okta
```
1. Search for user not in database (but in Okta)
2. Select ADMIN role
3. Click Assign Role
4. Verify user is created AND role assigned
5. Check statistics update (+1 ADMIN)
```
### Test 3: Self-Demotion Prevention
```
1. Login as ADMIN
2. Search for your own email
3. Try to assign USER or MANAGEMENT role
4. Verify error: "You cannot demote yourself from ADMIN role"
```
### Test 4: Role Statistics
```
1. Check statistics cards show correct counts
2. Assign roles to users
3. Verify statistics update in real-time
```
---
## 🔧 Backend Implementation Details
### Controller: `admin.controller.ts`
**New Function: `assignRoleByEmail`**
```typescript
1. Validate email and role
2. Check if user exists in database
3. If NOT exists:
a. Import UserService
b. Search Okta by email
c. If not found in Okta → return 404
d. If found → Create user with assigned role
4. If EXISTS:
a. Check for self-demotion
b. Update user's role
5. Return success response
```
---
## 📁 Files Modified
### Frontend (3 new, 2 modified)
```
✨ src/components/admin/UserRoleManager/UserRoleManager.tsx (NEW)
✨ src/components/admin/UserRoleManager/index.ts (NEW)
✨ Re_Figma_Code/USER_ROLE_MANAGEMENT.md (NEW - this file)
✏️ src/services/userApi.ts (MODIFIED - added 4 functions)
✏️ src/pages/Settings/Settings.tsx (MODIFIED - added User Roles tab)
```
### Backend (2 modified)
```
✏️ src/controllers/admin.controller.ts (MODIFIED - added assignRoleByEmail)
✏️ src/routes/admin.routes.ts (MODIFIED - added POST /users/assign-role)
```
---
## 🎉 Complete Feature Set
✅ Search users from Okta
✅ Create users from Okta if they don't exist
✅ Assign any of 3 roles (USER, MANAGEMENT, ADMIN)
✅ View role statistics
✅ View all elevated users (ADMIN + MANAGEMENT)
✅ Regular users hidden (don't clutter the list)
✅ Self-demotion prevention
✅ Real-time search with debouncing
✅ Beautiful UI with gradient cards
✅ Role badges with icons
✅ Success/error messaging
✅ Loading states
✅ Test IDs for testing
✅ Mobile responsive
✅ Admin-only access
---
## 🚀 Ready to Use!
The feature is fully functional and ready for testing. Admins can now easily manage user roles directly from the Settings page without needing SQL or manual database access!
**To test:**
1. Log in as ADMIN user
2. Navigate to Settings
3. Click "User Roles" tab
4. Start assigning roles! 🎯

View File

@ -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

19
fix-imports.ps1 Normal file
View File

@ -0,0 +1,19 @@
# Fix all imports with version numbers
$files = Get-ChildItem -Path "src" -Filter "*.tsx" -Recurse
foreach ($file in $files) {
$content = Get-Content $file.FullName -Raw
# Remove version numbers from ALL package imports (universal pattern)
# Matches: package-name@version, @scope/package-name@version
$content = $content -replace '(from\s+[''"])([^''"]+)@[\d.]+([''"])', '$1$2$3'
$content = $content -replace '(import\s+[''"])([^''"]+)@[\d.]+([''"])', '$1$2$3'
# Also fix motion/react to framer-motion
$content = $content -replace 'motion/react', 'framer-motion'
Set-Content -Path $file.FullName -Value $content -NoNewline
}
Write-Host "Fixed all imports!" -ForegroundColor Green

View File

@ -2,8 +2,6 @@
<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" />

53
migrate-files.ps1 Normal file
View File

@ -0,0 +1,53 @@
# PowerShell script to migrate files to src directory
# Run this script: .\migrate-files.ps1
Write-Host "Starting file migration to src directory..." -ForegroundColor Green
# Check if src directory exists
if (-not (Test-Path "src")) {
Write-Host "Creating src directory..." -ForegroundColor Yellow
New-Item -ItemType Directory -Path "src" -Force | Out-Null
}
# Function to move files with checks
function Move-WithCheck {
param($Source, $Destination)
if (Test-Path $Source) {
Write-Host "Moving $Source to $Destination..." -ForegroundColor Cyan
Move-Item -Path $Source -Destination $Destination -Force
Write-Host "Moved $Source" -ForegroundColor Green
} else {
Write-Host "$Source not found, skipping..." -ForegroundColor Yellow
}
}
# Move App.tsx
if (Test-Path "App.tsx") {
Move-WithCheck "App.tsx" "src\App.tsx"
}
# Move components directory
if (Test-Path "components") {
Move-WithCheck "components" "src\components"
}
# Move utils directory
if (Test-Path "utils") {
Move-WithCheck "utils" "src\utils"
}
# Move styles directory
if (Test-Path "styles") {
Move-WithCheck "styles" "src\styles"
}
Write-Host ""
Write-Host "Migration complete!" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host "1. Update imports in src/App.tsx to use '@/' aliases" -ForegroundColor White
Write-Host "2. Fix the sonner import: import { toast } from 'sonner';" -ForegroundColor White
Write-Host "3. Run: npm run dev" -ForegroundColor White
Write-Host ""
Write-Host "See MIGRATION_GUIDE.md for detailed instructions" -ForegroundColor Cyan

28
package-lock.json generated
View File

@ -52,9 +52,7 @@
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.0",
"react-router-dom": "^7.9.4",
"recharts": "^2.13.3",
"socket.io-client": "^4.8.1",
@ -6119,22 +6117,6 @@
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
"version": "7.53.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz",
"integrity": "sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -6221,16 +6203,6 @@
}
}
},
"node_modules/react-resizable-panels": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.0.tgz",
"integrity": "sha512-k2gGjGyCNF9xq8gVkkHBK1mlWv6xetPtvRdEtD914gTdhJcy02TLF0xMPuVLlGRuLoWGv7Gd/O1rea2KIQb3Qw==",
"license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-router": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",

View File

@ -57,9 +57,7 @@
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.0",
"react-router-dom": "^7.9.4",
"recharts": "^2.13.3",
"socket.io-client": "^4.8.1",

97
setup-env.bat Normal file
View File

@ -0,0 +1,97 @@
@echo off
REM Environment Setup Script for Royal Enfield Workflow Frontend (Windows)
echo ==================================================
echo Royal Enfield - Frontend Environment Setup
echo ==================================================
echo.
echo This script will create environment configuration files for your frontend.
echo.
REM Check if files already exist
if exist ".env.local" (
echo WARNING: .env.local already exists
set FILE_EXISTS=1
)
if exist ".env.production" (
echo WARNING: .env.production already exists
set FILE_EXISTS=1
)
if defined FILE_EXISTS (
echo.
set /p OVERWRITE="Do you want to OVERWRITE existing files? (y/n): "
if /i not "%OVERWRITE%"=="y" (
echo Aborted. No files were modified.
exit /b 0
)
)
REM Create .env.example
echo # API Configuration> .env.example
echo # Backend API base URL (with /api/v1)>> .env.example
echo VITE_API_BASE_URL=http://localhost:5000/api/v1>> .env.example
echo.>> .env.example
echo # Base URL for direct file access (without /api/v1)>> .env.example
echo VITE_BASE_URL=http://localhost:5000>> .env.example
echo Created .env.example
REM Create .env.local
echo # Local Development Environment> .env.local
echo VITE_API_BASE_URL=http://localhost:5000/api/v1>> .env.local
echo VITE_BASE_URL=http://localhost:5000>> .env.local
echo Created .env.local (for local development)
REM Create .env.production
echo.
echo ==================================================
echo Production Environment Configuration
echo ==================================================
echo.
set /p BACKEND_URL="Enter your PRODUCTION backend URL (e.g., https://api.yourcompany.com): "
if "%BACKEND_URL%"=="" (
echo WARNING: No backend URL provided. Creating template file...
echo # Production Environment> .env.production
echo # IMPORTANT: Update these URLs with your actual deployed backend URL>> .env.production
echo VITE_API_BASE_URL=https://your-backend-url.com/api/v1>> .env.production
echo VITE_BASE_URL=https://your-backend-url.com>> .env.production
) else (
REM Remove trailing slash if present
if "%BACKEND_URL:~-1%"=="/" set BACKEND_URL=%BACKEND_URL:~0,-1%
echo # Production Environment> .env.production
echo VITE_API_BASE_URL=%BACKEND_URL%/api/v1>> .env.production
echo VITE_BASE_URL=%BACKEND_URL%>> .env.production
echo Created .env.production with backend URL: %BACKEND_URL%
)
echo.
echo ==================================================
echo Setup Complete!
echo ==================================================
echo.
echo Next Steps:
echo.
echo 1. For LOCAL development:
echo npm run dev
echo (will use .env.local automatically)
echo.
echo 2. For PRODUCTION deployment:
echo - If deploying to Vercel/Netlify/etc:
echo Set environment variables in your platform dashboard
echo - If using Docker/VM:
echo Ensure .env.production has correct URLs
echo.
echo 3. Update Okta Configuration:
echo - Add production callback URL to Okta app settings
echo - Sign-in redirect URI: https://your-frontend.com/login/callback
echo.
echo 4. Update Backend CORS:
echo - Add production frontend URL to CORS allowed origins
echo.
echo For detailed instructions, see: DEPLOYMENT_CONFIGURATION.md
echo.
pause

View File

@ -15,13 +15,6 @@ VITE_API_BASE_URL=http://localhost:5000/api/v1
# Base URL for direct file access (without /api/v1)
VITE_BASE_URL=http://localhost:5000
# Okta Authentication Configuration
VITE_OKTA_DOMAIN=
VITE_OKTA_CLIENT_ID=
# Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=
EOF
echo "✅ Created .env.example"
}
@ -32,13 +25,6 @@ create_env_local() {
# Local Development Environment
VITE_API_BASE_URL=http://localhost:5000/api/v1
VITE_BASE_URL=http://localhost:5000
# Okta Authentication Configuration
VITE_OKTA_DOMAIN=
VITE_OKTA_CLIENT_ID=
# Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=
EOF
echo "✅ Created .env.local (for local development)"
}
@ -51,55 +37,25 @@ create_env_production() {
echo "=================================================="
echo ""
read -p "Enter your PRODUCTION backend URL (e.g., https://api.yourcompany.com): " BACKEND_URL
read -p "Enter your Okta Domain (e.g., https://your-org.okta.com): " OKTA_DOMAIN
read -p "Enter your Okta Client ID: " OKTA_CLIENT_ID
read -p "Enter your VAPID Public Key (for push notifications, optional): " VAPID_KEY
# Remove trailing slash if present
if [ ! -z "$BACKEND_URL" ]; then
BACKEND_URL=${BACKEND_URL%/}
fi
if [ ! -z "$OKTA_DOMAIN" ]; then
OKTA_DOMAIN=${OKTA_DOMAIN%/}
fi
if [ -z "$BACKEND_URL" ]; then
echo "⚠️ No backend URL provided. Creating template file..."
cat > .env.production << 'EOF'
# Production Environment
# IMPORTANT: Update these values with your actual production configuration
# API Configuration
# IMPORTANT: Update these URLs with your actual deployed backend URL
VITE_API_BASE_URL=https://your-backend-url.com/api/v1
VITE_BASE_URL=https://your-backend-url.com
# Okta Authentication Configuration
VITE_OKTA_DOMAIN=
VITE_OKTA_CLIENT_ID=
# Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=
EOF
else
# Remove trailing slash if present
BACKEND_URL=${BACKEND_URL%/}
cat > .env.production << EOF
# Production Environment
# API Configuration
VITE_API_BASE_URL=${BACKEND_URL}/api/v1
VITE_BASE_URL=${BACKEND_URL}
# Okta Authentication Configuration
VITE_OKTA_DOMAIN=${OKTA_DOMAIN}
VITE_OKTA_CLIENT_ID=${OKTA_CLIENT_ID}
# Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=${VAPID_KEY}
EOF
echo "✅ Created .env.production with:"
echo " - Backend URL: ${BACKEND_URL}"
[ ! -z "$OKTA_DOMAIN" ] && echo " - Okta Domain: ${OKTA_DOMAIN}"
[ ! -z "$OKTA_CLIENT_ID" ] && echo " - Okta Client ID: ${OKTA_CLIENT_ID}"
[ ! -z "$VAPID_KEY" ] && echo " - VAPID Key: Configured"
echo "✅ Created .env.production with backend URL: ${BACKEND_URL}"
fi
}
@ -143,7 +99,11 @@ echo " Set environment variables in your platform dashboard"
echo " - If using Docker/VM:"
echo " Ensure .env.production has correct URLs"
echo ""
echo "3. Update Backend CORS:"
echo "3. Update Okta Configuration:"
echo " - Add production callback URL to Okta app settings"
echo " - Sign-in redirect URI: https://your-frontend.com/login/callback"
echo ""
echo "4. Update Backend CORS:"
echo " - Add production frontend URL to CORS allowed origins"
echo ""
echo "📖 For detailed instructions, see: DEPLOYMENT_CONFIGURATION.md"

View File

@ -1,96 +1,37 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, useNavigate, Outlet } from 'react-router-dom';
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
import { PageLayout } from '@/components/layout/PageLayout';
import { Dashboard } from '@/pages/Dashboard';
import { OpenRequests } from '@/pages/OpenRequests';
import { ClosedRequests } from '@/pages/ClosedRequests';
import { RequestDetail } from '@/pages/RequestDetail';
import { SharedSummaries } from '@/pages/SharedSummaries/SharedSummaries';
import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail';
import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard';
import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
import { Profile } from '@/pages/Profile';
import { Settings } from '@/pages/Settings';
import { Notifications } from '@/pages/Notifications';
import { DetailedReports } from '@/pages/DetailedReports';
import { Admin } from '@/pages/Admin';
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi';
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
import { navigateToRequest } from '@/utils/requestNavigation';
// import { TokenManager } from '@/utils/tokenManager';
// Combined Request Database for backward compatibility
// This combines both custom and claim management requests
export const REQUEST_DATABASE: any = {
...CUSTOM_REQUEST_DATABASE,
...CLAIM_MANAGEMENT_DATABASE
};
interface AppProps {
onLogout?: () => void;
}
// Component to conditionally render Admin or User All Requests screen
// This ensures that when navigating from the sidebar, the correct screen is shown based on user role
function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) => void }) {
const { user } = useAuth();
const isAdmin = hasManagementAccess(user);
// Render separate screens based on user role
// Admin/Management users see all organization requests
// Regular users see only their participant requests (approver/spectator, NOT initiator)
if (isAdmin) {
return <Requests onViewRequest={onViewRequest} />;
} else {
return <UserAllRequests onViewRequest={onViewRequest} />;
}
}
// 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
function AppRoutes({ onLogout }: AppProps) {
const navigate = useNavigate();
@ -98,20 +39,6 @@ function AppRoutes({ onLogout }: AppProps) {
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
const [selectedRequestId, setSelectedRequestId] = useState<string>('');
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
const [managerModalOpen, setManagerModalOpen] = useState(false);
const [managerModalData, setManagerModalData] = useState<{
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
managers?: Array<{
userId: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
department?: string;
}>;
message?: string;
pendingClaimData?: any;
} | null>(null);
// Retrieve dynamic requests from localStorage on mount
useEffect(() => {
@ -143,27 +70,20 @@ function AppRoutes({ onLogout }: AppProps) {
navigate('/settings');
return;
}
// If page already starts with '/', use it directly (e.g., '/requests?status=approved')
// Otherwise, add leading slash (e.g., 'open-requests' -> '/open-requests')
if (page.startsWith('/')) {
navigate(page);
} else {
navigate(`/${page}`);
}
navigate(`/${page}`);
};
const handleViewRequest = (requestId: string, requestTitle?: string, status?: string, request?: any) => {
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => {
setSelectedRequestId(requestId);
setSelectedRequestTitle(requestTitle || 'Unknown Request');
// Use global navigation utility for consistent routing
navigateToRequest({
requestId,
requestTitle,
status,
request,
navigate,
});
// Check if request is a draft - if so, route to edit form instead of detail view
const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';
if (isDraft) {
navigate(`/edit-request/${requestId}`);
} else {
navigate(`/request/${requestId}`);
}
};
const handleBack = () => {
@ -184,14 +104,7 @@ function AppRoutes({ onLogout }: AppProps) {
return;
}
// If requestData has backendId, it means it came from the API flow (CreateRequest component)
// The hook already shows the toast, so we just navigate
if (requestData.backendId) {
navigate('/my-requests');
return;
}
// Regular custom request submission (old flow without API)
// Regular custom request submission
// 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')}`;
@ -292,10 +205,15 @@ function AppRoutes({ onLogout }: AppProps) {
// Add to dynamic requests
setDynamicRequests([...dynamicRequests, newCustomRequest]);
console.log('New custom request created:', newCustomRequest);
navigate('/my-requests');
toast.success('Request Submitted Successfully!', {
description: `Your request "${requestData.title}" (${requestId}) has been created and sent for approval.`,
duration: 5000,
});
};
const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => {
const handleApprovalSubmit = (action: 'approve' | 'reject', comment: string) => {
return new Promise((resolve) => {
setTimeout(() => {
if (action === 'approve') {
@ -310,6 +228,7 @@ function AppRoutes({ onLogout }: AppProps) {
});
}
console.log(`${action} action completed with comment:`, comment);
setApprovalAction(null);
resolve(true);
}, 1000);
@ -320,101 +239,58 @@ function AppRoutes({ onLogout }: AppProps) {
setApprovalAction(null);
};
const handleClaimManagementSubmit = async (claimData: any, _selectedManagerEmail?: string) => {
try {
// Prepare payload for API
const payload = {
activityName: claimData.activityName,
activityType: claimData.activityType,
dealerCode: claimData.dealerCode,
dealerName: claimData.dealerName,
dealerEmail: claimData.dealerEmail || undefined,
dealerPhone: claimData.dealerPhone || undefined,
dealerAddress: claimData.dealerAddress || undefined,
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toISOString() : undefined,
location: claimData.location,
requestDescription: claimData.requestDescription,
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
estimatedBudget: claimData.estimatedBudget || undefined,
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,
const handleOpenModal = (modal: string) => {
switch (modal) {
case 'work-note':
navigate(`/work-notes/${selectedRequestId}`);
break;
case 'internal-chat':
toast.success('Internal Chat Opened', {
description: 'Internal chat opened for request stakeholders.',
});
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,
break;
case 'approval-list':
toast.info('Approval List', {
description: 'Detailed approval workflow would be displayed.',
});
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,
});
break;
case 'approve':
setApprovalAction('approve');
break;
case 'reject':
setApprovalAction('reject');
break;
case 'escalate':
toast.warning('Request Escalated', {
description: 'The request has been escalated to higher authority.',
});
break;
case 'reminder':
toast.info('Reminder Sent', {
description: 'Reminder notification sent to current approver.',
});
break;
case 'add-approver':
toast.info('Add Approver', {
description: 'Add approver functionality would be implemented here.',
});
break;
case 'add-spectator':
toast.info('Add Spectator', {
description: 'Add spectator functionality would be implemented here.',
});
break;
case 'modify-sla':
toast.info('Modify SLA', {
description: 'SLA modification functionality would be implemented here.',
});
break;
default:
break;
}
};
// Keep the old code below for backward compatibility (local storage fallback)
// This can be removed once API integration is fully tested
/*
const handleClaimManagementSubmit = (claimData: any) => {
// Generate unique ID for the new claim request
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
@ -606,24 +482,23 @@ function AppRoutes({ onLogout }: AppProps) {
description: 'Your claim management request has been created successfully.',
});
navigate('/my-requests');
*/
};
return (
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
<Routes>
{/* Auth Callback - Unified callback for both OKTA and Tanflow */}
{/* Auth Callback - Must be before other routes */}
<Route
path="/login/callback"
element={<AuthCallback />}
/>
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
{/* Dashboard */}
<Route
path="/"
element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout>
}
/>
@ -632,67 +507,7 @@ function AppRoutes({ onLogout }: AppProps) {
path="/dashboard"
element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout>
}
/>
{/* Admin Routes Group with Shared Layout */}
<Route
element={
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Outlet />
</PageLayout>
}
>
<Route path="/admin/create-template" element={<CreateTemplate />} />
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
<Route path="/admin/templates" element={<AdminTemplatesList />} />
</Route>
{/* Create Request from Admin Template (Dedicated Flow) */}
<Route
path="/create-admin-request/:templateId"
element={
<CreateAdminRequest />
}
/>
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
{/* 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 />
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout>
}
/>
@ -717,26 +532,6 @@ function AppRoutes({ onLogout }: AppProps) {
}
/>
{/* Shared Summaries */}
<Route
path="/shared-summaries"
element={
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SharedSummaries />
</PageLayout>
}
/>
{/* Shared Summary Detail */}
<Route
path="/shared-summaries/:sharedSummaryId"
element={
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SharedSummaryDetail />
</PageLayout>
}
/>
{/* My Requests */}
<Route
path="/my-requests"
@ -747,26 +542,6 @@ function AppRoutes({ onLogout }: AppProps) {
}
/>
{/* Requests - Separate screens for Admin and Regular Users */}
<Route
path="/requests"
element={
<PageLayout currentPage="requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<RequestsRoute onViewRequest={handleViewRequest} />
</PageLayout>
}
/>
{/* Approver Performance - Detailed Performance Analysis */}
<Route
path="/approver-performance"
element={
<PageLayout currentPage="approver-performance" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<ApproverPerformance />
</PageLayout>
}
/>
{/* Request Detail - requestId will be read from URL params */}
<Route
path="/request/:requestId"
@ -862,9 +637,15 @@ function AppRoutes({ onLogout }: AppProps) {
}
/>
{/* Admin Control Panel */}
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
</Routes>
<Toaster
@ -878,27 +659,6 @@ function AppRoutes({ onLogout }: AppProps) {
}}
/>
{/* Manager Selection Modal */}
<ManagerSelectionModal
open={managerModalOpen}
onClose={() => {
setManagerModalOpen(false);
setManagerModalData(null);
}}
onSelect={async (managerEmail: string) => {
if (managerModalData?.pendingClaimData) {
// Retry creating claim request with selected manager
// The pendingClaimData contains all the form data from the wizard
// This preserves the entire submission state while waiting for manager selection
await handleClaimManagementSubmit(managerModalData.pendingClaimData, managerEmail);
}
}}
managers={managerModalData?.managers}
errorType={managerModalData?.errorType || 'NO_MANAGER_FOUND'}
message={managerModalData?.message}
isLoading={false} // Will be set to true during retry if needed
/>
{/* Approval Action Modal */}
{approvalAction && (
<ApprovalActionModal
@ -921,6 +681,8 @@ interface MainAppProps {
export default function App(props?: MainAppProps) {
const { onLogout } = props || {};
console.log('🟢 Main App component rendered');
console.log('🟢 onLogout prop received:', !!onLogout);
return (
<BrowserRouter>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -1,22 +0,0 @@
/**
* Assets Index
*
* Centralized exports for all assets (images, fonts, icons, etc.)
* This makes it easier to import assets throughout the application.
*/
// Images
export { default as ReLogo } from './images/Re_Logo.png';
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
export { default as LandingPageImage } from './images/landing_page_image.jpg';
// Fonts
// Add font exports here when fonts are added to the assets/fonts folder
// Example:
// export const FontName = './fonts/FontName.woff2';
// Icons
// Add icon exports here if needed
// Example:
// export { default as IconName } from './icons/icon-name.svg';

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@ -14,7 +14,14 @@ export function AuthDebugInfo({ isOpen, onClose }: AuthDebugInfoProps) {
const { user, isAuthenticated, isLoading, error } = useAuth0();
useEffect(() => {
// Auth state debug info - removed console.log
console.log('AuthDebugInfo - Current Auth State:', {
isAuthenticated,
isLoading,
hasUser: !!user,
error: error?.message,
userData: user,
timestamp: new Date().toISOString()
});
}, [user, isAuthenticated, isLoading, error]);
if (!isOpen) return null;

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Save, Loader2, Sparkles } from 'lucide-react';
import { Save, Loader2, Sparkles, Eye, EyeOff } from 'lucide-react';
import { AIProviderSettings } from './AIProviderSettings';
import { AIFeatures } from './AIFeatures';
import { AIParameters } from './AIParameters';
@ -11,6 +11,10 @@ import { toast } from 'sonner';
interface AIConfigData {
aiEnabled: boolean;
aiProvider: 'claude' | 'openai' | 'gemini';
claudeApiKey: string;
openaiApiKey: string;
geminiApiKey: string;
aiRemarkGeneration: boolean;
maxRemarkChars: number;
}
@ -18,10 +22,19 @@ interface AIConfigData {
export function AIConfig() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({
claude: false,
openai: false,
gemini: false
});
const [config, setConfig] = useState<AIConfigData>({
aiEnabled: true,
aiProvider: 'claude',
claudeApiKey: '',
openaiApiKey: '',
geminiApiKey: '',
aiRemarkGeneration: true,
maxRemarkChars: 2000
maxRemarkChars: 500
});
useEffect(() => {
@ -41,8 +54,12 @@ export function AIConfig() {
setConfig({
aiEnabled: configMap['AI_ENABLED'] === 'true',
aiProvider: (configMap['AI_PROVIDER'] || 'claude') as 'claude' | 'openai' | 'gemini',
claudeApiKey: configMap['CLAUDE_API_KEY'] || '',
openaiApiKey: configMap['OPENAI_API_KEY'] || '',
geminiApiKey: configMap['GEMINI_API_KEY'] || '',
aiRemarkGeneration: configMap['AI_REMARK_GENERATION_ENABLED'] === 'true',
maxRemarkChars: parseInt(configMap['AI_MAX_REMARK_LENGTH'] || '2000')
maxRemarkChars: parseInt(configMap['AI_REMARK_MAX_CHARACTERS'] || '500')
});
} catch (error: any) {
console.error('Failed to load AI configurations:', error);
@ -59,8 +76,12 @@ export function AIConfig() {
// Save all configurations
await Promise.all([
updateConfiguration('AI_ENABLED', config.aiEnabled.toString()),
updateConfiguration('AI_PROVIDER', config.aiProvider),
updateConfiguration('CLAUDE_API_KEY', config.claudeApiKey),
updateConfiguration('OPENAI_API_KEY', config.openaiApiKey),
updateConfiguration('GEMINI_API_KEY', config.geminiApiKey),
updateConfiguration('AI_REMARK_GENERATION_ENABLED', config.aiRemarkGeneration.toString()),
updateConfiguration('AI_MAX_REMARK_LENGTH', config.maxRemarkChars.toString())
updateConfiguration('AI_REMARK_MAX_CHARACTERS', config.maxRemarkChars.toString())
]);
toast.success('AI configuration saved successfully');
@ -79,6 +100,19 @@ export function AIConfig() {
setConfig(prev => ({ ...prev, ...updates }));
};
const toggleApiKeyVisibility = (provider: 'claude' | 'openai' | 'gemini') => {
setShowApiKeys(prev => ({
...prev,
[provider]: !prev[provider]
}));
};
const maskApiKey = (key: string): string => {
if (!key || key.length === 0) return '';
if (key.length <= 8) return '••••••••';
return key.substring(0, 4) + '••••••••' + key.substring(key.length - 4);
};
if (loading) {
return (
<Card className="shadow-lg border-0 rounded-md">
@ -94,13 +128,13 @@ export function AIConfig() {
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<div className="p-3 bg-re-green rounded-md shadow-md">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<CardTitle className="text-lg font-semibold text-gray-900">AI Features Configuration</CardTitle>
<CardDescription className="text-sm text-gray-600">
Configure Vertex AI Gemini settings and enable/disable AI-powered features
Configure AI provider, API keys, and enable/disable AI-powered features
</CardDescription>
</div>
</div>
@ -108,7 +142,18 @@ export function AIConfig() {
<CardContent className="space-y-6">
<AIProviderSettings
aiEnabled={config.aiEnabled}
aiProvider={config.aiProvider}
claudeApiKey={config.claudeApiKey}
openaiApiKey={config.openaiApiKey}
geminiApiKey={config.geminiApiKey}
showApiKeys={showApiKeys}
onAiEnabledChange={(enabled) => updateConfig({ aiEnabled: enabled })}
onProviderChange={(provider) => updateConfig({ aiProvider: provider })}
onClaudeApiKeyChange={(key) => updateConfig({ claudeApiKey: key })}
onOpenaiApiKeyChange={(key) => updateConfig({ openaiApiKey: key })}
onGeminiApiKeyChange={(key) => updateConfig({ geminiApiKey: key })}
onToggleApiKeyVisibility={toggleApiKeyVisibility}
maskApiKey={maskApiKey}
/>
<Separator />

View File

@ -26,19 +26,19 @@ export function AIParameters({
<CardContent>
<div className="space-y-2">
<Label htmlFor="max-remark-chars" className="text-sm font-medium">
Maximum Remark Length
Maximum Remark Characters
</Label>
<Input
id="max-remark-chars"
type="number"
min="500"
max="5000"
min="100"
max="2000"
value={maxRemarkChars}
onChange={(e) => onMaxRemarkCharsChange(parseInt(e.target.value) || 2000)}
onChange={(e) => onMaxRemarkCharsChange(parseInt(e.target.value) || 500)}
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
/>
<p className="text-xs text-muted-foreground">
Maximum character length for AI-generated conclusion remarks (500-5000 characters)
Maximum character limit for AI-generated conclusion remarks (100-2000 characters)
</p>
</div>
</CardContent>

View File

@ -1,25 +1,85 @@
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Brain } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Brain, Eye, EyeOff, Key } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface AIProviderSettingsProps {
aiEnabled: boolean;
aiProvider: 'claude' | 'openai' | 'gemini';
claudeApiKey: string;
openaiApiKey: string;
geminiApiKey: string;
showApiKeys: Record<string, boolean>;
onAiEnabledChange: (enabled: boolean) => void;
onProviderChange: (provider: 'claude' | 'openai' | 'gemini') => void;
onClaudeApiKeyChange: (key: string) => void;
onOpenaiApiKeyChange: (key: string) => void;
onGeminiApiKeyChange: (key: string) => void;
onToggleApiKeyVisibility: (provider: 'claude' | 'openai' | 'gemini') => void;
maskApiKey: (key: string) => string;
}
const PROVIDERS = [
{ value: 'claude', label: 'Claude (Anthropic)', description: 'Advanced AI by Anthropic' },
{ value: 'openai', label: 'OpenAI (GPT-4)', description: 'GPT-4 by OpenAI' },
{ value: 'gemini', label: 'Gemini (Google)', description: 'Gemini by Google' }
];
export function AIProviderSettings({
aiEnabled,
onAiEnabledChange
aiProvider,
claudeApiKey,
openaiApiKey,
geminiApiKey,
showApiKeys,
onAiEnabledChange,
onProviderChange,
onClaudeApiKeyChange,
onOpenaiApiKeyChange,
onGeminiApiKeyChange,
onToggleApiKeyVisibility,
maskApiKey
}: AIProviderSettingsProps) {
const getCurrentApiKey = (provider: 'claude' | 'openai' | 'gemini'): string => {
switch (provider) {
case 'claude':
return claudeApiKey;
case 'openai':
return openaiApiKey;
case 'gemini':
return geminiApiKey;
}
};
const getApiKeyChangeHandler = (provider: 'claude' | 'openai' | 'gemini') => {
switch (provider) {
case 'claude':
return onClaudeApiKeyChange;
case 'openai':
return onOpenaiApiKeyChange;
case 'gemini':
return onGeminiApiKeyChange;
}
};
return (
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Brain className="w-5 h-5 text-re-green" />
<CardTitle className="text-base font-semibold">Vertex AI Gemini Configuration</CardTitle>
<CardTitle className="text-base font-semibold">AI Provider & API Keys</CardTitle>
</div>
<CardDescription className="text-sm">
Configure AI features. Model and region are configured via environment variables.
Select your AI provider and configure API keys
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -36,7 +96,145 @@ export function AIProviderSettings({
onCheckedChange={onAiEnabledChange}
/>
</div>
{aiEnabled && (
<>
{/* Provider Selection */}
<div className="space-y-2">
<Label htmlFor="ai-provider" className="text-sm font-medium">
AI Provider
</Label>
<Select value={aiProvider} onValueChange={(value: any) => onProviderChange(value)}>
<SelectTrigger
id="ai-provider"
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
>
<SelectValue placeholder="Select AI provider" />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((provider) => (
<SelectItem key={provider.value} value={provider.value}>
<div className="flex items-center gap-2">
<span className="font-medium">{provider.label}</span>
<span className="text-xs text-muted-foreground">-</span>
<span className="text-xs text-muted-foreground">{provider.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* API Keys for each provider */}
<div className="space-y-4 pt-2">
<Label className="text-sm font-medium flex items-center gap-2">
<Key className="w-4 h-4" />
API Keys
</Label>
{/* Claude API Key */}
<div className="space-y-2">
<Label htmlFor="claude-key" className="text-xs text-muted-foreground">
Claude API Key
</Label>
<div className="flex gap-2">
<Input
id="claude-key"
type={showApiKeys.claude ? 'text' : 'password'}
value={claudeApiKey}
onChange={(e) => onClaudeApiKeyChange(e.target.value)}
placeholder={showApiKeys.claude ? "sk-ant-..." : maskApiKey(claudeApiKey) || "sk-ant-..."}
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => onToggleApiKeyVisibility('claude')}
className="border-gray-200"
>
{showApiKeys.claude ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Get your API key from console.anthropic.com
</p>
</div>
{/* OpenAI API Key */}
<div className="space-y-2">
<Label htmlFor="openai-key" className="text-xs text-muted-foreground">
OpenAI API Key
</Label>
<div className="flex gap-2">
<Input
id="openai-key"
type={showApiKeys.openai ? 'text' : 'password'}
value={openaiApiKey}
onChange={(e) => onOpenaiApiKeyChange(e.target.value)}
placeholder={showApiKeys.openai ? "sk-..." : maskApiKey(openaiApiKey) || "sk-..."}
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => onToggleApiKeyVisibility('openai')}
className="border-gray-200"
>
{showApiKeys.openai ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Get your API key from platform.openai.com
</p>
</div>
{/* Gemini API Key */}
<div className="space-y-2">
<Label htmlFor="gemini-key" className="text-xs text-muted-foreground">
Gemini API Key
</Label>
<div className="flex gap-2">
<Input
id="gemini-key"
type={showApiKeys.gemini ? 'text' : 'password'}
value={geminiApiKey}
onChange={(e) => onGeminiApiKeyChange(e.target.value)}
placeholder={showApiKeys.gemini ? "AIza..." : maskApiKey(geminiApiKey) || "AIza..."}
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => onToggleApiKeyVisibility('gemini')}
className="border-gray-200"
>
{showApiKeys.gemini ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Get your API key from ai.google.dev
</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@ -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>
);
}

View File

@ -32,6 +32,7 @@ export function AnalyticsConfig() {
const handleSave = () => {
// TODO: Implement API call to save configuration
console.log('Saving analytics configuration:', config);
toast.success('Analytics configuration saved successfully');
};

View File

@ -153,6 +153,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
const renderConfigInput = (config: AdminConfiguration) => {
const currentValue = getCurrentValue(config);
const isChanged = hasChanges(config);
const isSaving = saving === config.configKey;
if (!config.isEditable) {
@ -202,11 +203,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
min={min}
max={max}
step={1}
onValueChange={([value]) => {
if (value !== undefined) {
handleValueChange(config.configKey, value.toString());
}
}}
onValueChange={([value]) => handleValueChange(config.configKey, value.toString())}
disabled={isSaving}
className="w-full"
/>
@ -258,28 +255,34 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
}
};
// Filter out dashboard layout category and specific config keys
const excludedCategories = ['DASHBOARD_LAYOUT'];
const excludedConfigKeys = ['ALLOW_EXTERNAL_SHARING', 'NOTIFICATION_BATCH_DELAY_MS', 'AI_REMARK_MAX_CHARACTERS'];
const filteredConfigurations = configurations.filter(
config => !excludedCategories.includes(config.configCategory) &&
!excludedConfigKeys.includes(config.configKey)
);
const getCategoryColor = (category: string) => {
switch (category) {
case 'TAT_SETTINGS':
return 'bg-blue-100 text-blue-600';
case 'DOCUMENT_POLICY':
return 'bg-purple-100 text-purple-600';
case 'NOTIFICATION_RULES':
return 'bg-amber-100 text-amber-600';
case 'AI_CONFIGURATION':
return 'bg-pink-100 text-pink-600';
case 'WORKFLOW_SHARING':
return 'bg-emerald-100 text-emerald-600';
default:
return 'bg-gray-100 text-gray-600';
}
};
const groupedConfigs = filteredConfigurations.reduce((acc, config) => {
const groupedConfigs = configurations.reduce((acc, config) => {
if (!acc[config.configCategory]) {
acc[config.configCategory] = [];
}
acc[config.configCategory]!.push(config);
acc[config.configCategory].push(config);
return acc;
}, {} as Record<string, AdminConfiguration[]>);
// Sort configs within each category by sortOrder
Object.keys(groupedConfigs).forEach(category => {
const categoryConfigs = groupedConfigs[category];
if (categoryConfigs) {
categoryConfigs.sort((a, b) => a.sortOrder - b.sortOrder);
}
groupedConfigs[category].sort((a, b) => a.sortOrder - b.sortOrder);
});
if (loading) {
@ -290,7 +293,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
);
}
if (filteredConfigurations.length === 0) {
if (configurations.length === 0) {
return (
<Card className="shadow-lg border-0 rounded-md">
<CardContent className="p-12 text-center">
@ -359,23 +362,21 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4 border-b border-slate-100">
<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">
<div className="text-white">
<div className={`p-2.5 rounded-md shadow-sm ${getCategoryColor(category)}`}>
{getCategoryIcon(category)}
</div>
</div>
<div>
<CardTitle className="text-lg font-semibold text-slate-900">
{category.replace(/_/g, ' ')}
</CardTitle>
<CardDescription className="text-sm">
{groupedConfigs[category]?.length || 0} setting{(groupedConfigs[category]?.length || 0) !== 1 ? 's' : ''} available
{groupedConfigs[category].length} setting{groupedConfigs[category].length !== 1 ? 's' : ''} available
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{groupedConfigs[category]?.map(config => (
{groupedConfigs[category].map(config => (
<div key={config.configKey} className="space-y-3 pb-6 border-b border-slate-100 last:border-b-0 last:pb-0 hover:bg-slate-50/50 -mx-6 px-6 py-4 rounded-md transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
@ -413,7 +414,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
size="sm"
onClick={() => handleSave(config)}
disabled={!hasChanges(config) || saving === config.configKey}
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
className="gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving === config.configKey ? (
<>

View File

@ -60,6 +60,7 @@ export function DashboardConfig() {
const handleSave = () => {
// TODO: Implement API call to save dashboard configuration
console.log('Saving dashboard configuration:', config);
toast.success('Dashboard layout saved successfully');
};
@ -91,7 +92,7 @@ export function DashboardConfig() {
<RoleDashboardSection
key={role}
role={role}
kpis={config[role] || {}}
kpis={config[role]}
onKPIToggle={(kpi, checked) => handleKPIToggle(role, kpi, checked)}
/>
))}

View File

@ -93,7 +93,7 @@ export function DocumentConfig() {
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<div className="p-3 bg-re-green rounded-md shadow-md">
<FileText className="h-5 w-5 text-white" />
</div>
<div>

View File

@ -27,6 +27,7 @@ import {
Loader2,
AlertCircle,
CheckCircle,
Upload
} from 'lucide-react';
import { getAllHolidays, createHoliday, updateHoliday, deleteHoliday, Holiday } from '@/services/adminApi';
import { formatDateShort } from '@/utils/dateFormatter';
@ -88,17 +89,6 @@ export function HolidayManager() {
setShowAddDialog(true);
};
// Calculate minimum date (tomorrow for new holidays, no restriction for editing)
const getMinDate = () => {
// Only enforce minimum date when adding new holidays, not when editing existing ones
if (editingHoliday) {
return undefined; // Allow editing past holidays
}
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toISOString().split('T')[0];
};
const handleSave = async () => {
try {
setError(null);
@ -209,7 +199,7 @@ export function HolidayManager() {
<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">
<div className="p-2.5 bg-gradient-to-br from-blue-500 to-blue-600 rounded-md shadow-md">
<Calendar className="w-5 h-5 text-white" />
</div>
<div>
@ -234,7 +224,7 @@ export function HolidayManager() {
</Select>
<Button
onClick={handleAdd}
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm flex-1 sm:flex-initial"
className="gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 shadow-sm flex-1 sm:flex-initial"
>
<Plus className="w-4 h-4" />
<span className="hidden xs:inline">Add Holiday</span>
@ -277,7 +267,7 @@ export function HolidayManager() {
<div>
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">{month} {selectedYear}</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{holidaysByMonth[month]?.length || 0} holiday{(holidaysByMonth[month]?.length || 0) !== 1 ? 's' : ''}
{holidaysByMonth[month].length} holiday{holidaysByMonth[month].length !== 1 ? 's' : ''}
</CardDescription>
</div>
<div className="p-2 bg-blue-50 rounded-md">
@ -286,7 +276,7 @@ export function HolidayManager() {
</div>
</CardHeader>
<CardContent className="space-y-3 pt-4">
{holidaysByMonth[month]?.map(holiday => (
{holidaysByMonth[month].map(holiday => (
<div
key={holiday.holidayId}
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"
@ -340,151 +330,97 @@ export function HolidayManager() {
{/* 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">
<Calendar className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<DialogTitle className="text-xl font-semibold text-slate-900">
{editingHoliday ? 'Edit Holiday' : 'Add New Holiday'}
</DialogTitle>
<DialogDescription className="text-sm text-slate-600 mt-1">
{editingHoliday ? 'Update holiday information' : 'Add a new holiday to the calendar for TAT calculations'}
</DialogDescription>
</div>
</div>
<DialogContent className="sm:max-w-[500px] rounded-md">
<DialogHeader>
<DialogTitle className="text-lg sm:text-xl font-semibold text-slate-900">
{editingHoliday ? 'Edit Holiday' : 'Add New Holiday'}
</DialogTitle>
<DialogDescription className="text-sm text-slate-600">
{editingHoliday ? 'Update holiday information' : 'Add a new holiday to the calendar'}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-6 px-6 overflow-y-auto flex-1 min-h-0">
{/* Date Field */}
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="date" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Date <span className="text-red-500">*</span>
</Label>
<Label htmlFor="date" className="text-sm font-medium text-slate-900">Date *</Label>
<Input
id="date"
type="date"
value={formData.holidayDate}
onChange={(e) => setFormData({ ...formData, holidayDate: e.target.value })}
min={getMinDate()}
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm"
/>
<p className="text-xs text-slate-500">
{editingHoliday ? 'Select the holiday date' : 'Select the holiday date (minimum: tomorrow)'}
</p>
</div>
{/* Holiday Name Field */}
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Holiday Name <span className="text-red-500">*</span>
</Label>
<Label htmlFor="name" className="text-sm font-medium text-slate-900">Holiday Name *</Label>
<Input
id="name"
placeholder="e.g., Diwali, Republic Day, Christmas"
placeholder="e.g., Diwali, Republic Day"
value={formData.holidayName}
onChange={(e) => setFormData({ ...formData, holidayName: 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"
className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm"
/>
<p className="text-xs text-slate-500">Enter the official name of the holiday</p>
</div>
{/* Description Field */}
<div className="space-y-2">
<Label htmlFor="description" className="text-sm font-semibold text-slate-900">
Description <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label>
<Label htmlFor="description" className="text-sm font-medium text-slate-900">Description</Label>
<Input
id="description"
placeholder="Add additional details about this holiday..."
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: 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"
className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm"
/>
<p className="text-xs text-slate-500">Optional description or notes about the holiday</p>
</div>
{/* Holiday Type Field */}
<div className="space-y-2">
<Label htmlFor="type" className="text-sm font-semibold text-slate-900">
Holiday Type
</Label>
<Label htmlFor="type" className="text-sm font-medium text-slate-900">Holiday Type</Label>
<Select
value={formData.holidayType}
onValueChange={(value: Holiday['holidayType']) =>
setFormData({ ...formData, holidayType: value })
}
>
<SelectTrigger id="type" className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm">
<SelectTrigger id="type" className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-lg">
<SelectItem value="NATIONAL" className="p-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500"></div>
<span>National</span>
</div>
</SelectItem>
<SelectItem value="REGIONAL" className="p-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
<span>Regional</span>
</div>
</SelectItem>
<SelectItem value="ORGANIZATIONAL" className="p-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-purple-500"></div>
<span>Organizational</span>
</div>
</SelectItem>
<SelectItem value="OPTIONAL" className="p-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-500"></div>
<span>Optional</span>
</div>
</SelectItem>
<SelectContent className="rounded-md">
<SelectItem value="NATIONAL">National</SelectItem>
<SelectItem value="REGIONAL">Regional</SelectItem>
<SelectItem value="ORGANIZATIONAL">Organizational</SelectItem>
<SelectItem value="OPTIONAL">Optional</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500">Select the category of this holiday</p>
</div>
{/* Recurring Checkbox */}
<div className="flex items-start gap-3 p-4 bg-gradient-to-br from-slate-50 to-slate-100/50 border-2 border-slate-200 rounded-lg hover:border-slate-300 hover:bg-slate-100 transition-all cursor-pointer group" onClick={() => setFormData({ ...formData, isRecurring: !formData.isRecurring })}>
<div className="flex items-center gap-2 p-3 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 transition-colors">
<input
type="checkbox"
id="recurring"
checked={formData.isRecurring}
onChange={(e) => setFormData({ ...formData, isRecurring: e.target.checked })}
className="mt-0.5 rounded border-slate-300 text-re-green focus:ring-2 focus:ring-re-green/20 focus:ring-offset-0 w-4 h-4 cursor-pointer"
className="rounded border-slate-300 text-blue-600 focus:ring-2 focus:ring-blue-500/20 focus:ring-offset-0 w-4 h-4 cursor-pointer"
/>
<div className="flex-1">
<Label htmlFor="recurring" className="font-semibold cursor-pointer text-sm text-slate-900 block mb-1">
Recurring Holiday
</Label>
<p className="text-xs text-slate-600">
This holiday will automatically repeat every year on the same date
</p>
</div>
<Label htmlFor="recurring" className="font-normal cursor-pointer text-sm text-slate-700">
This holiday recurs annually
</Label>
</div>
</div>
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setShowAddDialog(false)}
className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
className="border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!formData.holidayDate || !formData.holidayName}
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"
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 shadow-sm"
>
<Calendar className="w-4 h-4 mr-2" />
{editingHoliday ? 'Update Holiday' : 'Add Holiday'}
{editingHoliday ? 'Update' : 'Add'} Holiday
</Button>
</DialogFooter>
</DialogContent>

View File

@ -29,6 +29,7 @@ export function NotificationConfig() {
const handleSave = () => {
// TODO: Implement API call to save notification configuration
console.log('Saving notification configuration:', config);
toast.success('Notification configuration saved successfully');
};

View File

@ -24,6 +24,7 @@ export function SharingConfig() {
const handleSave = () => {
// TODO: Implement API call to save sharing configuration
console.log('Saving sharing configuration:', config);
toast.success('Sharing policy saved successfully');
};

View File

@ -42,11 +42,7 @@ export function EscalationSettings({
min={1}
max={100}
step={1}
onValueChange={([value]) => {
if (value !== undefined) {
onReminderThreshold1Change(value);
}
}}
onValueChange={([value]) => onReminderThreshold1Change(value)}
className="w-full"
/>
<p className="text-xs text-muted-foreground flex items-center gap-1">
@ -68,11 +64,7 @@ export function EscalationSettings({
min={1}
max={100}
step={1}
onValueChange={([value]) => {
if (value !== undefined) {
onReminderThreshold2Change(value);
}
}}
onValueChange={([value]) => onReminderThreshold2Change(value)}
className="w-full"
/>
<p className="text-xs text-muted-foreground flex items-center gap-1">

View File

@ -114,7 +114,7 @@ export function TATConfig() {
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<div className="p-3 bg-re-green rounded-md shadow-md">
<Clock className="h-5 w-5 text-white" />
</div>
<div>

View File

@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } 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 { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
@ -14,11 +15,15 @@ import {
Search,
Users,
Shield,
UserCog,
Loader2,
CheckCircle,
AlertCircle,
Crown,
User as UserIcon,
Edit,
Trash2,
Power
} from 'lucide-react';
import { userApi } from '@/services/userApi';
import { toast } from 'sonner';
@ -89,17 +94,14 @@ export function UserManagement() {
// Search users from Okta
const searchUsers = useCallback(
debounce(async (query: string) => {
// Only trigger search when using @ sign
if (!query || !query.startsWith('@') || query.length < 2) {
if (!query || query.length < 2) {
setSearchResults([]);
setSearching(false);
return;
}
setSearching(true);
try {
const term = query.slice(1); // Remove @ prefix
const response = await userApi.searchUsers(term, 20);
const response = await userApi.searchUsers(query, 20);
const users = response.data?.data || [];
setSearchResults(users);
} catch (error: any) {
@ -353,6 +355,27 @@ export function UserManagement() {
};
}, [searchResults]);
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'ADMIN':
return 'bg-yellow-400 text-slate-900';
case 'MANAGEMENT':
return 'bg-blue-400 text-slate-900';
default:
return 'bg-gray-400 text-white';
}
};
const getRoleIcon = (role: string) => {
switch (role) {
case 'ADMIN':
return <Crown className="w-5 h-5" />;
case 'MANAGEMENT':
return <Users className="w-5 h-5" />;
default:
return <UserIcon className="w-5 h-5" />;
}
};
// Calculate stats for UserStatsCards
const stats = {
@ -400,7 +423,7 @@ export function UserManagement() {
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Type @ to search users..."
placeholder="Type name or email address..."
value={searchQuery}
onChange={handleSearchChange}
className="pl-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
@ -410,7 +433,7 @@ export function UserManagement() {
)}
</div>
</div>
<p className="text-xs text-muted-foreground">Start with @ to search users (e.g., @john)</p>
<p className="text-xs text-muted-foreground">Start typing to search across all Okta users</p>
{/* Search Results Dropdown */}
{searchResults.length > 0 && (

View File

@ -1,6 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@ -47,20 +46,7 @@ interface OktaUser {
lastName?: string;
displayName?: string;
department?: string;
phone?: string;
mobilePhone?: string;
designation?: string;
jobTitle?: string;
manager?: string;
employeeId?: string;
employeeNumber?: string;
secondEmail?: string;
location?: {
state?: string;
city?: string;
country?: string;
office?: string;
};
}
interface UserWithRole {
@ -100,21 +86,21 @@ export function UserRoleManager() {
// Search users from Okta
const searchUsers = useCallback(
debounce(async (query: string) => {
// Only trigger search when using @ sign
if (!query || !query.startsWith('@') || query.length < 2) {
if (!query || query.length < 2) {
setSearchResults([]);
setSearching(false);
return;
}
setSearching(true);
try {
const term = query.slice(1); // Remove @ prefix
const response = await userApi.searchUsers(term, 20);
const response = await userApi.searchUsers(query, 20);
console.log('Search response:', response);
console.log('Response.data:', response.data);
// Backend returns { success: true, data: [...users], message, timestamp }
// Axios response is in response.data, actual user array is in response.data.data
const users = response.data?.data || [];
console.log('Parsed users:', users);
setSearchResults(users);
} catch (error: any) {
@ -138,41 +124,10 @@ export function UserRoleManager() {
};
// Select user from search results
const handleSelectUser = async (user: OktaUser) => {
const handleSelectUser = (user: OktaUser) => {
setSelectedUser(user);
setSearchQuery(user.email);
setSearchResults([]);
// Check if user already exists in the current users list and has a role assigned
const existingUser = users.find(u =>
u.email.toLowerCase() === user.email.toLowerCase() ||
u.userId === user.userId
);
if (existingUser && existingUser.role) {
// Pre-select the user's current role
setSelectedRole(existingUser.role);
} else {
// If user doesn't exist in current list, check all users in database
try {
const allUsers = await userApi.getAllUsers();
const foundUser = allUsers.find((u: any) =>
(u.email && u.email.toLowerCase() === user.email.toLowerCase()) ||
(u.userId && u.userId === user.userId)
);
if (foundUser && foundUser.role) {
setSelectedRole(foundUser.role);
} else {
// Default to USER if user doesn't exist
setSelectedRole('USER');
}
} catch (error) {
console.error('Failed to check user role:', error);
// Default to USER on error
setSelectedRole('USER');
}
}
};
// Assign role to user
@ -187,7 +142,6 @@ export function UserRoleManager() {
try {
// Call backend to assign role (will create user if doesn't exist)
// Backend will fetch user data from Okta if user doesn't exist
await userApi.assignRole(selectedUser.email, selectedRole);
setMessage({
@ -220,11 +174,17 @@ export function UserRoleManager() {
try {
const response = await userApi.getUsersByRole(roleFilter, page, limit);
console.log('Users response:', response);
// Backend returns { success: true, data: { users: [...], pagination: {...}, summary: {...} } }
const usersData = response.data?.data?.users || [];
const paginationData = response.data?.data?.pagination;
const summaryData = response.data?.data?.summary;
console.log('Parsed users:', usersData);
console.log('Pagination:', paginationData);
console.log('Summary:', summaryData);
setUsers(usersData);
if (paginationData) {
@ -252,9 +212,11 @@ export function UserRoleManager() {
const fetchRoleStatistics = async () => {
try {
const response = await userApi.getRoleStatistics();
console.log('Role statistics response:', response);
// Handle different response formats
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
console.log('Statistics data:', statsData);
setRoleStats({
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
@ -320,43 +282,29 @@ export function UserRoleManager() {
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'ADMIN':
return 'bg-yellow-400 text-slate-800';
return 'bg-yellow-400 text-slate-900';
case 'MANAGEMENT':
return 'bg-blue-400 text-slate-800';
return 'bg-blue-400 text-slate-900';
default:
return 'bg-gray-400 text-slate-800';
return 'bg-gray-400 text-white';
}
};
const getRoleIcon = (role: string) => {
switch (role) {
case 'ADMIN':
return <Crown className="w-5 h-5 text-slate-800" />;
return <Crown className="w-5 h-5" />;
case 'MANAGEMENT':
return <Users className="w-5 h-5 text-slate-800" />;
return <Users className="w-5 h-5" />;
default:
return <UserIcon className="w-5 h-5 text-slate-800" />;
return <UserIcon className="w-5 h-5" />;
}
};
return (
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<UserCog className="h-5 w-5 text-white" />
</div>
<div>
<CardTitle className="text-lg font-semibold text-gray-900">User Role Management</CardTitle>
<CardDescription className="text-sm text-gray-600">
Search for users, assign roles, and manage user permissions across the system
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
<div className="space-y-6">
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
<Card
className={`border-2 bg-gradient-to-br from-yellow-50 to-yellow-100/50 hover:shadow-lg transition-all rounded-xl cursor-pointer ${
roleFilter === 'ADMIN' ? 'border-yellow-400 shadow-lg' : 'border-transparent shadow-md'
@ -374,7 +322,7 @@ export function UserRoleManager() {
</p>
</div>
<div className="p-3 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-xl shadow-md">
<Crown className="w-6 h-6 text-slate-800" />
<Crown className="w-6 h-6 text-slate-900" />
</div>
</div>
</CardContent>
@ -397,7 +345,7 @@ export function UserRoleManager() {
</p>
</div>
<div className="p-3 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl shadow-md">
<Users className="w-6 h-6 text-slate-800" />
<Users className="w-6 h-6 text-slate-900" />
</div>
</div>
</CardContent>
@ -420,24 +368,29 @@ export function UserRoleManager() {
</p>
</div>
<div className="p-3 bg-gradient-to-br from-gray-400 to-gray-500 rounded-xl shadow-md">
<UserIcon className="w-6 h-6 text-slate-800" />
<UserIcon className="w-6 h-6 text-white" />
</div>
</div>
</CardContent>
</Card>
</div>
<Separator />
{/* Assign Role Section */}
<div className="space-y-5">
<div>
<h3 className="text-base font-semibold text-gray-900 mb-1">Assign User Role</h3>
<p className="text-sm text-gray-600">
Search for a user in Okta and assign them a role
</p>
</div>
<div className="space-y-5">
<Card className="shadow-lg border">
<CardHeader className="border-b pb-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg shadow-md">
<UserCog className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg font-semibold">Assign User Role</CardTitle>
<CardDescription className="text-sm">
Search for a user in Okta and assign them a role
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5 pt-6">
{/* Search Input */}
<div className="space-y-2" ref={searchContainerRef}>
<label className="text-sm font-medium text-gray-700">Search User</label>
@ -445,17 +398,17 @@ export function UserRoleManager() {
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
<Input
type="text"
placeholder="Type @ to search users..."
placeholder="Type name or email address..."
value={searchQuery}
onChange={handleSearchChange}
className="pl-10 pr-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
className="pl-10 pr-10 h-12 border rounded-lg border-gray-300 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
data-testid="user-search-input"
/>
{searching && (
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-re-green animate-spin" />
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-purple-500 animate-spin" />
)}
</div>
<p className="text-xs text-gray-500">Start with @ to search users (e.g., @john)</p>
<p className="text-xs text-gray-500">Start typing to search across all Okta users</p>
{/* Search Results Dropdown */}
{searchResults.length > 0 && (
@ -465,18 +418,18 @@ export function UserRoleManager() {
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
</p>
</div>
<div className="p-2">
<div className="p-3">
{searchResults.map((user) => (
<button
key={user.userId}
onClick={() => handleSelectUser(user)}
className="w-full text-left p-2 hover:bg-purple-50 rounded-lg transition-colors mb-1 last:mb-0"
className="w-full text-left p-3 hover:bg-purple-50 rounded-lg transition-colors mb-1 last:mb-0"
data-testid={`user-result-${user.email}`}
>
<p className="text-sm font-medium text-gray-900">{user.displayName || user.email}</p>
<p className="text-xs text-gray-600">{user.email}</p>
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
<p className="text-sm text-gray-600">{user.email}</p>
{user.department && (
<p className="text-xs text-gray-500">
<p className="text-xs text-gray-500 mt-1">
{user.department}{user.designation ? `${user.designation}` : ''}
</p>
)}
@ -489,19 +442,19 @@ export function UserRoleManager() {
{/* Selected User */}
{selectedUser && (
<div className="border-2 border-slate-300 bg-gradient-to-br from-slate-100 to-slate-50 rounded-lg p-4 shadow-sm">
<div className="border-2 border-purple-200 bg-purple-50 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-slate-700 to-slate-500 flex items-center justify-center text-white font-bold shadow-md">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md">
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
</div>
<div>
<p className="font-semibold text-slate-900">
<p className="font-semibold text-gray-900">
{selectedUser.displayName || selectedUser.email}
</p>
<p className="text-sm text-slate-600">{selectedUser.email}</p>
<p className="text-sm text-gray-600">{selectedUser.email}</p>
{selectedUser.department && (
<p className="text-xs text-slate-500 mt-1">
<p className="text-xs text-gray-500 mt-1">
{selectedUser.department}{selectedUser.designation ? `${selectedUser.designation}` : ''}
</p>
)}
@ -514,7 +467,7 @@ export function UserRoleManager() {
setSelectedUser(null);
setSearchQuery('');
}}
className="hover:bg-slate-200"
className="hover:bg-purple-100"
>
Clear
</Button>
@ -527,25 +480,25 @@ export function UserRoleManager() {
<label className="text-sm font-medium text-gray-700">Select Role</label>
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
<SelectTrigger
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
className="h-12 border border-gray-300 py-2 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200 transition-all"
data-testid="role-select"
>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USER">
<SelectContent className="rounded-lg">
<SelectItem value="USER" className="p-3 rounded-lg my-1">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4 text-gray-600" />
<span>User - Regular access</span>
</div>
</SelectItem>
<SelectItem value="MANAGEMENT">
<SelectItem value="MANAGEMENT" className="p-3 rounded-lg my-1">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-600" />
<span>Management - Read all data</span>
</div>
</SelectItem>
<SelectItem value="ADMIN">
<SelectItem value="ADMIN" className="p-3 rounded-lg my-1">
<div className="flex items-center gap-2">
<Crown className="w-4 h-4 text-yellow-600" />
<span>Administrator - Full access</span>
@ -559,7 +512,7 @@ export function UserRoleManager() {
<Button
onClick={handleAssignRole}
disabled={!selectedUser || updating}
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full h-12 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-50 rounded-lg"
data-testid="assign-role-button"
>
{updating ? (
@ -594,20 +547,25 @@ export function UserRoleManager() {
</div>
</div>
)}
</div>
</div>
<Separator />
</CardContent>
</Card>
{/* Users List with Filter and Pagination */}
<div ref={userListRef}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900 mb-1">User Management</h3>
<p className="text-sm text-gray-600">
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
</p>
</div>
<Card className="shadow-lg border">
<CardHeader className="border-b pb-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-lg shadow-md">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg font-semibold">User Management</CardTitle>
<CardDescription className="text-sm">
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
</CardDescription>
</div>
</div>
<div className="flex items-center gap-3">
<Select value={roleFilter} onValueChange={handleFilterChange}>
<SelectTrigger className="w-[200px] h-10 border rounded-lg border-gray-300">
@ -648,7 +606,8 @@ export function UserRoleManager() {
</Select>
</div>
</div>
<div className="pt-2">
</CardHeader>
<CardContent className="pt-6">
{loadingUsers ? (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
@ -673,7 +632,7 @@ export function UserRoleManager() {
{users.map((user) => (
<div
key={user.userId}
className="border border-gray-200 hover:border-re-green hover:shadow-sm transition-all rounded-lg bg-white p-4"
className="border-2 border-gray-100 hover:border-purple-200 hover:shadow-md transition-all rounded-lg bg-white p-4"
data-testid={`user-${user.email}`}
>
<div className="flex items-center justify-between gap-4">
@ -735,7 +694,7 @@ export function UserRoleManager() {
onClick={() => handlePageChange(pageNum)}
className={`w-9 h-9 p-0 ${
currentPage === pageNum
? 'bg-re-green hover:bg-re-green/90 text-white'
? 'bg-purple-500 hover:bg-purple-600'
: ''
}`}
data-testid={`page-${pageNum}-button`}
@ -759,10 +718,10 @@ export function UserRoleManager() {
)}
</>
)}
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</div>
);
}

View File

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

View File

@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { CheckCircle } from 'lucide-react';
import { CheckCircle, AlertCircle } from 'lucide-react';
type ApprovalModalProps = {
open: boolean;

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';

View File

@ -1,4 +1,4 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle, RefreshCw, ArrowLeft } from 'lucide-react';
@ -32,7 +32,7 @@ export class ErrorBoundary extends Component<Props, State> {
};
}
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error Boundary caught an error:', error, errorInfo);
this.setState({
@ -54,7 +54,7 @@ export class ErrorBoundary extends Component<Props, State> {
});
};
override render() {
render() {
if (this.state.hasError) {
// Custom fallback if provided
if (this.props.fallback) {

View File

@ -54,54 +54,23 @@ export function FilePreview({
setError(null);
try {
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
const token = isProduction ? null : localStorage.getItem('accessToken');
// Ensure we have a valid URL - handle relative URLs when served from same origin
let urlToFetch = fileUrl;
if (fileUrl.startsWith('/') && !fileUrl.startsWith('//')) {
// Relative URL - construct absolute URL using current origin
urlToFetch = `${window.location.origin}${fileUrl}`;
}
// Build headers - in production, rely on httpOnly cookies
const headers: HeadersInit = {
'Accept': isPDF ? 'application/pdf' : '*/*'
};
// Only add Authorization header in development mode
if (!isProduction && token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(urlToFetch, {
headers,
credentials: 'include', // Always include credentials for cookie-based auth
mode: 'cors'
const token = localStorage.getItem('accessToken');
const response = await fetch(fileUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(`Failed to load file: ${response.status} ${response.statusText}. ${errorText}`);
throw new Error('Failed to load file');
}
const blob = await response.blob();
// Check if blob is valid
if (blob.size === 0) {
throw new Error('File is empty or could not be loaded');
}
// Verify blob type matches expected type
if (isPDF && !blob.type.includes('pdf') && blob.type !== 'application/octet-stream') {
console.warn(`Expected PDF but got ${blob.type}`);
}
const url = window.URL.createObjectURL(blob);
setBlobUrl(url);
} catch (err) {
console.error('Failed to load file for preview:', err);
setError(err instanceof Error ? err.message : 'Failed to load file for preview');
setError('Failed to load file for preview');
} finally {
setLoading(false);
}
@ -113,10 +82,9 @@ export function FilePreview({
return () => {
if (blobUrl) {
window.URL.revokeObjectURL(blobUrl);
setBlobUrl(null);
}
};
}, [open, fileUrl, canPreview, isPDF]);
}, [open, fileUrl, canPreview]);
const handleDownload = async () => {
if (onDownload && attachmentId) {
@ -250,9 +218,6 @@ export function FilePreview({
minHeight: '70vh',
height: '100%'
}}
onError={() => {
setError('Failed to load PDF preview');
}}
/>
</div>
)}

View File

@ -1,77 +0,0 @@
import * as React from "react";
import { cn } from "@/components/ui/utils";
interface FormattedDescriptionProps {
content: string;
className?: string;
}
/**
* FormattedDescription Component
*
* Renders HTML content with proper styling for lists, tables, and other formatted content.
* Use this component to display descriptions that may contain HTML formatting.
*/
export function FormattedDescription({ content, className }: FormattedDescriptionProps) {
const processedContent = React.useMemo(() => {
if (!content) return '';
// Wrap tables that aren't already wrapped in a scrollable container using regex
// Match <table> tags that aren't already inside a .table-wrapper
let processed = content;
// Pattern to match table tags that aren't already wrapped
const tablePattern = /<table[^>]*>[\s\S]*?<\/table>/gi;
processed = processed.replace(tablePattern, (match) => {
// Check if this table is already wrapped
if (match.includes('table-wrapper')) {
return match;
}
// Wrap the table in a scrollable container
return `<div class="table-wrapper" style="overflow-x: auto; max-width: 100%; margin: 8px 0;">${match}</div>`;
});
return processed;
}, [content]);
if (!content) return null;
return (
<div
className={cn(
"text-sm text-gray-700 max-w-none",
// Horizontal scrolling for smaller screens
"overflow-x-auto",
"md:overflow-x-visible",
// Lists
"[&_ul]:list-disc [&_ul]:ml-6 [&_ul]:my-2 [&_ul]:list-outside",
"[&_ol]:list-decimal [&_ol]:ml-6 [&_ol]:my-2 [&_ol]:list-outside",
"[&_li]:my-1 [&_li]:pl-2",
// Table wrapper for scrolling
"[&_.table-wrapper]:overflow-x-auto [&_.table-wrapper]:max-w-full [&_.table-wrapper]:my-2 [&_.table-wrapper]:-mx-2 [&_.table-wrapper]:px-2",
"[&_.table-wrapper_table]:border-collapse [&_.table-wrapper_table]:border [&_.table-wrapper_table]:border-gray-300 [&_.table-wrapper_table]:min-w-full",
"[&_.table-wrapper_table_td]:border [&_.table-wrapper_table_td]:border-gray-300 [&_.table-wrapper_table_td]:px-3 [&_.table-wrapper_table_td]:py-2 [&_.table-wrapper_table_td]:text-sm [&_.table-wrapper_table_td]:whitespace-nowrap",
"[&_.table-wrapper_table_th]:border [&_.table-wrapper_table_th]:border-gray-300 [&_.table-wrapper_table_th]:px-3 [&_.table-wrapper_table_th]:py-2 [&_.table-wrapper_table_th]:bg-gray-50 [&_.table-wrapper_table_th]:font-semibold [&_.table-wrapper_table_th]:text-sm [&_.table-wrapper_table_th]:text-left [&_.table-wrapper_table_th]:whitespace-nowrap",
"[&_.table-wrapper_table_tr:nth-child(even)]:bg-gray-50",
// Direct table styles (fallback for tables not wrapped)
"[&_table]:border-collapse [&_table]:my-2 [&_table]:border [&_table]:border-gray-300",
"[&_table_td]:border [&_table_td]:border-gray-300 [&_table_td]:px-3 [&_table_td]:py-2 [&_table_td]:text-sm",
"[&_table_th]:border [&_table_th]:border-gray-300 [&_table_th]:px-3 [&_table_th]:py-2 [&_table_th]:bg-gray-50 [&_table_th]:font-semibold [&_table_th]:text-sm [&_table_th]:text-left",
"[&_table_tr:nth-child(even)]:bg-gray-50",
// Text formatting
"[&_p]:my-1 [&_p]:leading-relaxed",
"[&_strong]:font-bold",
"[&_em]:italic",
"[&_u]:underline",
"[&_h1]:text-xl [&_h1]:font-bold [&_h1]:my-2",
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2",
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1",
className
)}
dangerouslySetInnerHTML={{ __html: processedContent }}
/>
);
}

View File

@ -1,3 +1,4 @@
import React from 'react';
interface LoaderProps {
message?: string;

View File

@ -40,45 +40,20 @@ export function Pagination({
return pages;
};
// Calculate display values
const startItem = totalRecords > 0 ? ((currentPage - 1) * itemsPerPage) + 1 : 0;
const endItem = Math.min(currentPage * itemsPerPage, totalRecords);
// Always show the count info, even if there's only 1 page
// If only 1 page or loading, only show the count info without pagination controls
if (totalPages <= 1) {
return (
<Card className="shadow-md border-gray-200" data-testid={`${testIdPrefix}-container`}>
<CardContent className="p-4">
<div className="flex items-center justify-center">
<div
className="text-sm sm:text-base font-medium text-gray-700"
data-testid={`${testIdPrefix}-info`}
>
{loading ? (
`Loading ${itemLabel}...`
) : totalRecords === 0 ? (
`No ${itemLabel} found`
) : totalRecords === 1 ? (
`Showing 1 ${itemLabel.slice(0, -1)}`
) : startItem === endItem ? (
`Showing ${startItem} of ${totalRecords} ${itemLabel}`
) : (
`Showing ${startItem} to ${endItem} of ${totalRecords} ${itemLabel}`
)}
</div>
</div>
</CardContent>
</Card>
);
// Don't show pagination if only 1 page or loading
if (totalPages <= 1 || loading) {
return null;
}
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalRecords);
return (
<Card className="shadow-md border-gray-200" data-testid={`${testIdPrefix}-container`}>
<Card className="shadow-md" data-testid={`${testIdPrefix}-container`}>
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
<div
className="text-sm sm:text-base font-medium text-gray-700"
className="text-xs sm:text-sm text-muted-foreground"
data-testid={`${testIdPrefix}-info`}
>
Showing {startItem} to {endItem} of {totalRecords} {itemLabel}
@ -121,7 +96,7 @@ export function Pagination({
variant={pageNum === currentPage ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(pageNum)}
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
data-testid={`${testIdPrefix}-page-${pageNum}`}
aria-current={pageNum === currentPage ? 'page' : undefined}
>

View File

@ -1,5 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LucideIcon, Info } from 'lucide-react';
import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
interface KPICardProps {
@ -12,8 +12,6 @@ interface KPICardProps {
children?: ReactNode;
testId?: string;
onClick?: () => void;
onJustifyClick?: () => void;
showJustifyButton?: boolean;
}
export function KPICard({
@ -25,66 +23,45 @@ export function KPICard({
subtitle,
children,
testId = 'kpi-card',
onClick,
onJustifyClick,
showJustifyButton = false
onClick
}: KPICardProps) {
const handleJustifyClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card onClick from firing
onJustifyClick?.();
};
return (
<Card
className="hover:shadow-lg transition-all duration-200 shadow-md cursor-pointer h-full flex flex-col"
className="hover:shadow-lg transition-all duration-200 shadow-md cursor-pointer"
onClick={onClick}
data-testid={testId}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle
className="text-sm font-medium text-muted-foreground"
data-testid={`${testId}-title`}
>
{title}
</CardTitle>
<div className="flex items-center gap-2">
{showJustifyButton && onJustifyClick && (
<button
type="button"
onClick={handleJustifyClick}
className="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
data-testid={`${testId}-justify-button`}
title="View detailed breakdown of numbers"
aria-label="View detailed breakdown"
>
<Info className="h-4 w-4" />
</button>
)}
<div className={`p-1.5 sm:p-2 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
<Icon
className={`h-4 w-4 sm:h-4 sm:w-4 ${iconColor}`}
data-testid={`${testId}-icon`}
/>
</div>
<div className={`p-2 sm:p-3 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
<Icon
className={`h-4 w-4 sm:h-5 sm:w-5 ${iconColor}`}
data-testid={`${testId}-icon`}
/>
</div>
</CardHeader>
<CardContent className="flex flex-col flex-1 py-3">
<CardContent>
<div
className="text-xl sm:text-2xl font-bold text-gray-900 mb-2"
className="text-2xl sm:text-3xl font-bold text-gray-900 mb-3"
data-testid={`${testId}-value`}
>
{value}
</div>
{subtitle && (
<div
className="text-xs text-muted-foreground mb-2"
className="text-xs text-muted-foreground mb-3"
data-testid={`${testId}-subtitle`}
>
{subtitle}
</div>
)}
{children && (
<div className="flex-1 flex flex-col" data-testid={`${testId}-children`}>
<div data-testid={`${testId}-children`}>
{children}
</div>
)}

View File

@ -7,7 +7,6 @@ interface StatCardProps {
textColor: string;
testId?: string;
children?: ReactNode;
onClick?: (e: React.MouseEvent) => void;
}
export function StatCard({
@ -16,17 +15,15 @@ export function StatCard({
bgColor,
textColor,
testId = 'stat-card',
children,
onClick
children
}: StatCardProps) {
return (
<div
className={`${bgColor} rounded-lg p-2 sm:p-3 ${onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}`}
className={`${bgColor} rounded-lg p-2 sm:p-3`}
data-testid={testId}
onClick={onClick}
>
<p
className="text-xs text-gray-600 mb-1 leading-tight"
className="text-xs text-gray-600 mb-1"
data-testid={`${testId}-label`}
>
{label}

View File

@ -10,7 +10,6 @@ interface StatsCardProps {
textColor: string;
valueColor: string;
testId?: string;
onClick?: () => void;
}
export function StatsCard({
@ -21,14 +20,12 @@ export function StatsCard({
gradient,
textColor,
valueColor,
testId = 'stats-card',
onClick
testId = 'stats-card'
}: StatsCardProps) {
return (
<Card
className={`${gradient} border transition-shadow ${onClick ? 'cursor-pointer hover:shadow-lg' : 'hover:shadow-md'}`}
className={`${gradient} border transition-shadow hover:shadow-md`}
data-testid={testId}
onClick={onClick}
>
<CardContent className="p-3 sm:p-4">
<div className="flex items-center justify-between">

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { Bell, Settings, User, Plus, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List, Share2 } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, Activity, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
@ -15,11 +16,10 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useAuth } from '@/contexts/AuthContext';
import { ReLogo } from '@/assets';
import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png';
import notificationApi, { Notification } from '@/services/notificationApi';
import { getSocket, joinUserRoom } from '@/utils/socket';
import { formatDistanceToNow } from 'date-fns';
import { TokenManager } from '@/utils/tokenManager';
interface PageLayoutProps {
children: React.ReactNode;
@ -37,17 +37,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
const [notificationsOpen, setNotificationsOpen] = useState(false);
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
const getUserInitials = () => {
try {
@ -68,27 +57,13 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
}
};
const menuItems = useMemo(() => {
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: Home },
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
{ id: 'requests', label: 'All Requests', icon: List },
{ id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
];
// Add remaining menu items (exclude "My Requests" for dealers)
if (!isDealer) {
items.push({ id: 'my-requests', label: 'My Requests', icon: User });
}
items.push(
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
);
return items;
}, [isDealer]);
const menuItems = [
{ id: 'dashboard', label: 'Dashboard', icon: Home },
{ id: 'my-requests', label: 'My Requests', icon: User },
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
{ id: 'admin', label: 'Admin', icon: Shield },
];
const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen);
@ -115,13 +90,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
// Work note related notifications should open Work Notes tab
if (notification.notificationType === 'mention' ||
notification.notificationType === 'comment' ||
notification.notificationType === 'worknote') {
notification.notificationType === 'comment' ||
notification.notificationType === 'worknote') {
navigationUrl += '?tab=worknotes';
}
// Navigate to request detail page
onNavigate(navigationUrl);
console.log('[PageLayout] Navigating to:', navigationUrl);
}
}
@ -157,6 +133,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
const notifs = result.data?.notifications || [];
setNotifications(notifs);
setUnreadCount(result.data?.unreadCount || 0);
console.log('[PageLayout] Loaded', notifs.length, 'recent notifications,', result.data?.unreadCount, 'unread');
} catch (error) {
console.error('[PageLayout] Failed to fetch notifications:', error);
}
@ -165,7 +143,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
fetchNotifications();
// Setup socket for real-time notifications
const socket = getSocket(); // Uses getSocketBaseUrl() helper internally
const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000';
const socket = getSocket(baseUrl);
if (socket) {
// Join user's personal notification room
@ -173,6 +152,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
// Listen for new notifications
const handleNewNotification = (data: { notification: Notification }) => {
console.log('[PageLayout] 🔔 New notification received:', data);
if (!mounted) return;
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
@ -238,35 +218,35 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
`}>
<div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}>
<div className="p-4 border-b border-gray-800 flex-shrink-0">
<div className="flex flex-col items-center justify-center">
<div className="flex items-center gap-3">
<img
src={ReLogo}
src={royalEnfieldLogo}
alt="Royal Enfield Logo"
className="h-10 w-auto max-w-[168px] object-contain"
className="w-10 h-10 shrink-0 object-contain"
/>
<p className="text-xs text-gray-400 text-center mt-1 truncate">RE Flow</p>
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold text-white truncate">Royal Enfield</h2>
<p className="text-sm text-gray-400 truncate">Approval Portal</p>
</div>
</div>
</div>
<div className="p-3 flex-1 overflow-y-auto">
<div className="space-y-2">
{menuItems.filter(item => !item.adminOnly || (user as any)?.role === 'ADMIN').map((item) => (
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => {
if (item.id === 'admin/templates') {
onNavigate?.('admin/templates');
} else {
onNavigate?.(item.id);
}
onNavigate?.(item.id);
// Close sidebar on mobile after navigation
if (window.innerWidth < 768) {
setSidebarOpen(false);
}
}}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id
? 'bg-re-green text-white font-medium'
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
}`}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
currentPage === item.id
? 'bg-re-green text-white font-medium'
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
}`}
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="truncate">{item.label}</span>
@ -275,7 +255,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</div>
{/* Quick Action in Sidebar - Right below menu items */}
{/* {!isDealer && ( */}
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
<Button
onClick={onNewRequest}
@ -286,7 +265,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
Raise New Request
</Button>
</div>
{/* )} */}
</div>
</div>
</aside>
@ -296,35 +274,32 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{/* Header */}
<header className="h-16 border-b border-gray-200 bg-white flex items-center justify-between px-6 shrink-0">
<div className="flex items-center gap-4 min-w-0 flex-1">
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="shrink-0 h-10 w-10 sidebar-toggle"
>
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
</Button>
{/* Search bar commented out */}
{/* <div className="relative max-w-md flex-1">
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="shrink-0 h-10 w-10 sidebar-toggle"
>
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
</Button>
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search..."
className="pl-10 bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green w-full text-sm h-10"
/>
</div> */}
</div>
</div>
<div className="flex items-center gap-4 shrink-0">
{!isDealer && (
<Button
onClick={onNewRequest}
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
size="sm"
>
<Plus className="w-4 h-4" />
New Request
</Button>
)}
<Button
onClick={onNewRequest}
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
size="sm"
>
<Plus className="w-4 h-4" />
New Request
</Button>
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
<DropdownMenuTrigger asChild>
@ -365,8 +340,9 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{notifications.map((notif) => (
<div
key={notif.notificationId}
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${!notif.isRead ? 'bg-blue-50' : ''
}`}
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${
!notif.isRead ? 'bg-blue-50' : ''
}`}
onClick={() => handleNotificationClick(notif)}
>
<div className="flex gap-2">
@ -461,10 +437,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
console.log('🔴 Logout button clicked in PageLayout');
console.log('🔴 onLogout function exists?', !!onLogout);
setShowLogoutDialog(false);
if (onLogout) {
console.log('🔴 Calling onLogout function...');
try {
await onLogout();
console.log('🔴 onLogout completed');
} catch (error) {
console.error('🔴 Error calling onLogout:', error);
}

View File

@ -10,10 +10,11 @@ interface AddUserModalProps {
isOpen: boolean;
onClose: () => void;
type: 'approver' | 'spectator';
requestId: string;
requestTitle: string;
}
export function AddUserModal({ isOpen, onClose, type, requestTitle }: AddUserModalProps) {
export function AddUserModal({ isOpen, onClose, type, requestId, requestTitle }: AddUserModalProps) {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Textarea } from '../ui/textarea';

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Label } from '../ui/label';

View File

@ -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>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { Badge } from '../ui/badge';
import { Avatar, AvatarFallback } from '../ui/avatar';
import { Progress } from '../ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Switch } from '../ui/switch';
import { Calendar } from '../ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
@ -18,6 +18,8 @@ import {
Calendar as CalendarIcon,
Upload,
X,
User,
Clock,
FileText,
Check,
Users
@ -183,7 +185,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left">
<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>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">

View File

@ -1,88 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
interface PolicyViolationModalProps {
open: boolean;
onClose: () => void;
violations: Array<{
type: string;
message: string;
currentValue?: number;
maxValue?: number;
}>;
policyDetails?: {
maxApprovalLevels?: number;
maxParticipants?: number;
allowSpectators?: boolean;
maxSpectators?: number;
};
}
export function PolicyViolationModal({
open,
onClose,
violations,
policyDetails
}: PolicyViolationModalProps) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
Policy Violation
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<p className="text-gray-700">
The following policy violations were detected:
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{violations.map((violation, index) => (
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-medium text-red-900 text-sm">{violation.type}</p>
<p className="text-xs text-red-700 mt-1">{violation.message}</p>
{violation.currentValue !== undefined && violation.maxValue !== undefined && (
<p className="text-xs text-red-600 mt-1 font-semibold">
Current: {violation.currentValue} / Maximum: {violation.maxValue}
</p>
)}
</div>
))}
</div>
{policyDetails && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800 font-semibold mb-1">System Policy:</p>
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
{policyDetails.maxApprovalLevels !== undefined && (
<li>Maximum approval levels: {policyDetails.maxApprovalLevels}</li>
)}
{policyDetails.maxParticipants !== undefined && (
<li>Maximum participants per request: {policyDetails.maxParticipants}</li>
)}
{policyDetails.allowSpectators !== undefined && (
<li>Allow adding spectators: {policyDetails.allowSpectators ? 'Yes' : 'No'}</li>
)}
{policyDetails.maxSpectators !== undefined && (
<li>Maximum spectators per request: {policyDetails.maxSpectators}</li>
)}
</ul>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={onClose}
className="w-full sm:w-auto"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,238 +0,0 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2, Search, User, X } from 'lucide-react';
import { toast } from 'sonner';
import { shareSummary } from '@/services/summaryApi';
import { searchUsers } from '@/services/userApi';
interface ShareSummaryModalProps {
isOpen: boolean;
onClose: () => void;
summaryId: string;
requestTitle: string;
onSuccess?: () => void;
}
export function ShareSummaryModal({ isOpen, onClose, summaryId, requestTitle, onSuccess }: ShareSummaryModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [users, setUsers] = useState<Array<{ userId: string; email: string; displayName?: string; designation?: string; department?: string }>>([]);
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set());
const [searching, setSearching] = useState(false);
const [sharing, setSharing] = useState(false);
// Search users - only when input starts with @
useEffect(() => {
if (!isOpen) {
setUsers([]);
return;
}
// Only trigger search when using @ sign
if (!searchTerm || !searchTerm.startsWith('@') || searchTerm.length < 2) {
setUsers([]);
setSearching(false);
return;
}
const searchTimeout = setTimeout(async () => {
try {
setSearching(true);
const term = searchTerm.slice(1); // Remove @ prefix
const response = await searchUsers(term, 10);
const results = response?.data?.data || response?.data || [];
setUsers(Array.isArray(results) ? results : []);
} catch (error) {
console.error('Failed to search users:', error);
toast.error('Failed to search users');
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(searchTimeout);
}, [searchTerm, isOpen]);
const handleToggleUser = (userId: string) => {
setSelectedUserIds(prev => {
const newSet = new Set(prev);
if (newSet.has(userId)) {
newSet.delete(userId);
} else {
newSet.add(userId);
}
return newSet;
});
};
const handleShare = async () => {
if (selectedUserIds.size === 0) {
toast.error('Please select at least one user to share with');
return;
}
try {
setSharing(true);
await shareSummary(summaryId, Array.from(selectedUserIds));
toast.success(`Summary shared with ${selectedUserIds.size} user(s)`);
setSelectedUserIds(new Set());
setSearchTerm('');
setUsers([]);
onSuccess?.();
onClose();
} catch (error: any) {
console.error('Failed to share summary:', error);
toast.error(error?.response?.data?.message || 'Failed to share summary');
} finally {
setSharing(false);
}
};
const handleClose = () => {
setSelectedUserIds(new Set());
setSearchTerm('');
setUsers([]);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Share Summary</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-sm font-medium text-gray-700">Request</Label>
<p className="text-sm text-gray-600 mt-1">{requestTitle}</p>
</div>
<div>
<Label htmlFor="user-search" className="text-sm font-medium text-gray-700">
Search Users
</Label>
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="user-search"
placeholder="Type @ to search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{searchTerm && !searchTerm.startsWith('@') && (
<p className="text-xs text-gray-500 mt-1 ml-1">
Start with @ to search users (e.g., @john)
</p>
)}
</div>
{searching && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
</div>
)}
{!searching && users.length > 0 && (
<div className="border rounded-lg max-h-[300px] overflow-y-auto">
{users.map((user) => {
const isSelected = selectedUserIds.has(user.userId);
return (
<div
key={user.userId}
className="flex items-center gap-3 p-3 hover:bg-gray-50 border-b last:border-b-0 cursor-pointer"
onClick={() => handleToggleUser(user.userId)}
>
<div
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="flex items-center"
>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleUser(user.userId)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-400 flex-shrink-0" />
<p className="text-sm font-medium text-gray-900 truncate">
{user.displayName || user.email}
</p>
</div>
{(user.designation || user.department) && (
<p className="text-xs text-gray-500 mt-0.5">{user.designation || user.department}</p>
)}
<p className="text-xs text-gray-400 truncate">{user.email}</p>
</div>
</div>
);
})}
</div>
)}
{!searching && searchTerm && searchTerm.startsWith('@') && users.length === 0 && (
<div className="text-center py-8 text-gray-500 text-sm">
No users found
</div>
)}
{!searching && searchTerm && !searchTerm.startsWith('@') && (
<div className="text-center py-8 text-gray-500 text-sm">
Start typing with @ to search users
</div>
)}
{selectedUserIds.size > 0 && (
<div className="border rounded-lg p-3 bg-blue-50">
<p className="text-sm font-medium text-gray-700 mb-2">
Selected ({selectedUserIds.size})
</p>
<div className="flex flex-wrap gap-2">
{Array.from(selectedUserIds).map((userId) => {
const user = users.find(u => u.userId === userId);
return (
<div
key={userId}
className="flex items-center gap-1 bg-white px-2 py-1 rounded-full text-xs"
>
<span>{user?.displayName || user?.email || userId}</span>
<button
onClick={() => handleToggleUser(userId)}
className="ml-1 hover:text-red-600"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={sharing}>
Cancel
</Button>
<Button onClick={handleShare} disabled={sharing || selectedUserIds.size === 0}>
{sharing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sharing...
</>
) : (
`Share with ${selectedUserIds.size} user(s)`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,23 +1,24 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Separator } from '../ui/separator';
import {
FileText,
Receipt,
Package,
TrendingUp,
Users,
ArrowRight,
ArrowLeft,
Clock,
CheckCircle,
Target,
X,
Sparkles,
Check,
AlertCircle
Check
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { TokenManager } from '../../utils/tokenManager';
interface TemplateSelectionModalProps {
open: boolean;
@ -41,8 +42,7 @@ const AVAILABLE_TEMPLATES = [
'Document verification',
'E-invoice generation',
'Credit note issuance'
],
disabled: false
]
},
{
id: 'vendor-payment',
@ -58,32 +58,14 @@ const AVAILABLE_TEMPLATES = [
'Invoice verification',
'Multi-level approvals',
'Payment scheduling'
],
disabled: true,
comingSoon: true
]
}
];
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
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) => {
// 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);
};
@ -105,13 +87,12 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
</DialogDescription>
{/* Back arrow button - Top left */}
{/* Custom Close button */}
<button
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"
aria-label="Go back"
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"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
<X className="w-5 h-5 text-gray-600" />
</button>
{/* Full Screen Content Container */}
@ -139,7 +120,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
{AVAILABLE_TEMPLATES.map((template, index) => {
const Icon = template.icon;
const isSelected = selectedTemplate === template.id;
const isDisabled = isDealer || template.disabled;
return (
<motion.div
@ -147,16 +127,14 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={isDisabled ? {} : { scale: 1.03 }}
whileTap={isDisabled ? {} : { scale: 0.98 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
>
<Card
className={`h-full transition-all duration-300 border-2 ${
isDisabled
? 'opacity-50 cursor-not-allowed border-gray-200'
: isSelected
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
isSelected
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
}`}
onClick={() => handleSelect(template.id)}
>
@ -182,22 +160,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
<CardDescription className="text-sm leading-relaxed">
{template.description}
</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>
</CardHeader>
<CardContent className="pt-0 space-y-4">
@ -260,12 +222,12 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
</Button>
<Button
onClick={handleContinue}
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
disabled={!selectedTemplate}
size="lg"
className={`gap-2 px-8 ${
selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
selectedTemplate
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-400 cursor-not-allowed'
: 'bg-gray-400'
}`}
>
Continue with Template

View File

@ -38,6 +38,7 @@ interface WorkNoteModalProps {
export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps) {
const [message, setMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const participants = [
@ -138,12 +139,10 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
const extractMentions = (text: string): string[] => {
const mentionRegex = /@(\w+\s?\w+)/g;
const mentions: string[] = [];
const mentions = [];
let match;
while ((match = mentionRegex.exec(text)) !== null) {
if (match[1]) {
mentions.push(match[1]);
}
mentions.push(match[1]);
}
return mentions;
};
@ -231,6 +230,23 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
</div>
</div>
))}
{isTyping && (
<div className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-gray-400 text-white text-xs">
...
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex gap-1">
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
Someone is typing...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>

View File

@ -24,18 +24,16 @@ interface AddApproverModalProps {
requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
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({
open,
onClose,
onConfirm,
requestIdDisplay,
requestTitle,
existingParticipants = [],
currentLevels = [],
maxApprovalLevels,
onPolicyViolation
currentLevels = []
}: AddApproverModalProps) {
const [email, setEmail] = useState('');
const [tatHours, setTatHours] = useState<number>(24);
@ -144,36 +142,6 @@ export function AddApproverModal({
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
const existingParticipant = existingParticipants.find(
p => (p.email || '').toLowerCase() === emailToAdd
@ -244,17 +212,10 @@ export function AddApproverModal({
displayName: foundUser.displayName,
firstName: foundUser.firstName,
lastName: foundUser.lastName,
department: foundUser.department,
phone: foundUser.phone,
mobilePhone: foundUser.mobilePhone,
designation: foundUser.designation,
jobTitle: foundUser.jobTitle,
manager: foundUser.manager,
employeeId: foundUser.employeeId,
employeeNumber: foundUser.employeeNumber,
secondEmail: foundUser.secondEmail,
location: foundUser.location
department: foundUser.department
});
console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`);
} catch (error) {
console.error('Failed to validate approver:', error);
setValidationModal({
@ -374,22 +335,14 @@ export function AddApproverModal({
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
department: user.department,
phone: user.phone,
mobilePhone: user.mobilePhone,
designation: user.designation,
jobTitle: user.jobTitle,
manager: user.manager,
employeeId: user.employeeId,
employeeNumber: user.employeeNumber,
secondEmail: user.secondEmail,
location: user.location
department: user.department
});
setEmail(user.email);
setSelectedUser(user); // Track that user was selected via @ search
setSearchResults([]);
setIsSearching(false);
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
} catch (error) {
console.error('Failed to ensure user exists:', error);
setValidationModal({
@ -428,20 +381,6 @@ export function AddApproverModal({
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
</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 */}
{currentLevels.length > 0 && (
<div className="space-y-2">

View File

@ -5,8 +5,6 @@ import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
interface AddSpectatorModalProps {
open: boolean;
@ -21,6 +19,8 @@ export function AddSpectatorModal({
open,
onClose,
onConfirm,
requestIdDisplay,
requestTitle,
existingParticipants = []
}: AddSpectatorModalProps) {
const [email, setEmail] = useState('');
@ -44,57 +44,6 @@ export function AddSpectatorModal({
message: ''
});
// Policy violation modal state
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
// System policy configuration state
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
// Fetch system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const workflowConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const tatConfigs = await getPublicConfigurations('TAT_SETTINGS');
const allConfigs = [...workflowConfigs, ...tatConfigs];
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);
// Use defaults if loading fails
}
};
if (open) {
loadSystemPolicy();
}
}, [open]);
const handleConfirm = async () => {
const emailToAdd = email.trim().toLowerCase();
@ -164,55 +113,6 @@ export function AddSpectatorModal({
}
}
// Policy validation before adding spectator
const violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }> = [];
// Check if spectators are allowed
if (!systemPolicy.allowSpectators) {
violations.push({
type: 'Spectators Not Allowed',
message: `Adding spectators is not allowed by system policy.`,
});
}
// Count existing spectators
const existingSpectators = existingParticipants.filter(
p => (p.participantType || '').toUpperCase() === 'SPECTATOR'
);
const currentSpectatorCount = existingSpectators.length;
// Check maximum spectators
if (currentSpectatorCount >= systemPolicy.maxSpectators) {
violations.push({
type: 'Maximum Spectators Exceeded',
message: `This request has reached the maximum number of spectators allowed.`,
currentValue: currentSpectatorCount,
maxValue: systemPolicy.maxSpectators
});
}
// Count existing participants (initiator + approvers + spectators)
const totalParticipants = existingParticipants.length + 1; // +1 for the new spectator
// Check maximum participants
if (totalParticipants > systemPolicy.maxParticipants) {
violations.push({
type: 'Maximum Participants Exceeded',
message: `Adding this spectator would exceed the maximum participants limit.`,
currentValue: totalParticipants,
maxValue: systemPolicy.maxParticipants
});
}
// If there are policy violations, show modal and return
if (violations.length > 0) {
setPolicyViolationModal({
open: true,
violations
});
return;
}
// If user was NOT selected via @ search, validate against Okta
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
try {
@ -239,17 +139,10 @@ export function AddSpectatorModal({
displayName: foundUser.displayName,
firstName: foundUser.firstName,
lastName: foundUser.lastName,
department: foundUser.department,
phone: foundUser.phone,
mobilePhone: foundUser.mobilePhone,
designation: foundUser.designation,
jobTitle: foundUser.jobTitle,
manager: foundUser.manager,
employeeId: foundUser.employeeId,
employeeNumber: foundUser.employeeNumber,
secondEmail: foundUser.secondEmail,
location: foundUser.location
department: foundUser.department
});
console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`);
} catch (error) {
console.error('Failed to validate spectator:', error);
setValidationModal({
@ -355,22 +248,14 @@ export function AddSpectatorModal({
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
department: user.department,
phone: user.phone,
mobilePhone: user.mobilePhone,
designation: user.designation,
jobTitle: user.jobTitle,
manager: user.manager,
employeeId: user.employeeId,
employeeNumber: user.employeeNumber,
secondEmail: user.secondEmail,
location: user.location
department: user.department
});
setEmail(user.email);
setSelectedUser(user); // Track that user was selected via @ search
setSearchResults([]);
setIsSearching(false);
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
} catch (error) {
console.error('Failed to ensure user exists:', error);
setValidationModal({
@ -562,19 +447,6 @@ export function AddSpectatorModal({
</DialogFooter>
</DialogContent>
</Dialog>
{/* 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
}}
/>
</Dialog>
);
}

View File

@ -1,192 +0,0 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Bell, Mail, MessageSquare, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { getNotificationPreferences, updateNotificationPreferences, NotificationPreferences } from '@/services/userPreferenceApi';
export function NotificationPreferencesCard() {
const [preferences, setPreferences] = useState<NotificationPreferences>({
emailNotificationsEnabled: true,
pushNotificationsEnabled: true,
inAppNotificationsEnabled: true
});
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadPreferences();
}, []);
const loadPreferences = async () => {
try {
setLoading(true);
setError(null);
const data = await getNotificationPreferences();
setPreferences(data);
} catch (err: any) {
console.error('[NotificationPreferences] Failed to load preferences:', err);
setError(err.response?.data?.message || 'Failed to load notification preferences');
} finally {
setLoading(false);
}
};
const handleToggle = async (key: keyof NotificationPreferences, value: boolean) => {
try {
setUpdating(key);
setError(null);
setSuccessMessage(null);
const updateData = { [key]: value };
const updated = await updateNotificationPreferences(updateData);
setPreferences(updated);
// Show success message
const prefName = key === 'emailNotificationsEnabled' ? 'Email'
: key === 'pushNotificationsEnabled' ? 'Push'
: 'In-App';
setSuccessMessage(`${prefName} notifications ${value ? 'enabled' : 'disabled'}`);
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err: any) {
console.error('[NotificationPreferences] Failed to update preference:', err);
setError(err.response?.data?.message || 'Failed to update notification preference');
// Revert the UI change
loadPreferences();
} finally {
setUpdating(null);
}
};
if (loading) {
return (
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md">
<CardContent className="p-12 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</CardContent>
</Card>
);
}
return (
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Bell className="h-5 w-5 text-white" />
</div>
<div>
<CardTitle className="text-lg font-semibold text-gray-900">Notification Preferences</CardTitle>
<CardDescription className="text-sm text-gray-600">Control how you receive notifications</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Success Message */}
{successMessage && (
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-md">
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
<p className="text-sm text-green-800 font-medium">{successMessage}</p>
</div>
)}
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md">
<AlertCircle className="w-4 h-4 text-red-600 shrink-0" />
<p className="text-sm text-red-800 font-medium">{error}</p>
</div>
)}
{/* Email Notifications */}
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm">
<Mail className="w-5 h-5 text-slate-600" />
</div>
<div>
<Label htmlFor="email-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
Email Notifications
</Label>
<p className="text-xs text-gray-600 mt-0.5">Receive notifications via email</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'emailNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="email-notifications"
checked={preferences.emailNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('emailNotificationsEnabled', checked)}
disabled={updating === 'emailNotificationsEnabled'}
/>
</div>
</div>
{/* Push Notifications */}
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm">
<Bell className="w-5 h-5 text-slate-600" />
</div>
<div>
<Label htmlFor="push-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
Push Notifications
</Label>
<p className="text-xs text-gray-600 mt-0.5">Receive browser push notifications</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'pushNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="push-notifications"
checked={preferences.pushNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('pushNotificationsEnabled', checked)}
disabled={updating === 'pushNotificationsEnabled'}
/>
</div>
</div>
{/* In-App Notifications */}
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm">
<MessageSquare className="w-5 h-5 text-slate-600" />
</div>
<div>
<Label htmlFor="inapp-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
In-App Notifications
</Label>
<p className="text-xs text-gray-600 mt-0.5">Show notifications within the application</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'inAppNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="inapp-notifications"
checked={preferences.inAppNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('inAppNotificationsEnabled', checked)}
disabled={updating === 'inAppNotificationsEnabled'}
/>
</div>
</div>
<div className="pt-4 border-t border-gray-200">
<p className="text-xs text-gray-500 text-center">
These preferences control which notification channels you receive alerts through.
You'll still receive critical notifications regardless of these settings.
</p>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,224 +0,0 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Bell, Mail, MessageSquare, Loader2, CheckCircle, AlertCircle, Settings } from 'lucide-react';
import { getNotificationPreferences, updateNotificationPreferences, NotificationPreferences } from '@/services/userPreferenceApi';
import { Separator } from '@/components/ui/separator';
interface NotificationPreferencesModalProps {
open: boolean;
onClose: () => void;
}
export function NotificationPreferencesModal({ open, onClose }: NotificationPreferencesModalProps) {
const [preferences, setPreferences] = useState<NotificationPreferences>({
emailNotificationsEnabled: true,
pushNotificationsEnabled: true,
inAppNotificationsEnabled: true
});
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
loadPreferences();
}
}, [open]);
const loadPreferences = async () => {
try {
setLoading(true);
setError(null);
const data = await getNotificationPreferences();
setPreferences(data);
} catch (err: any) {
console.error('[NotificationPreferences] Failed to load preferences:', err);
setError(err.response?.data?.message || 'Failed to load notification preferences');
} finally {
setLoading(false);
}
};
const handleToggle = async (key: keyof NotificationPreferences, value: boolean) => {
try {
setUpdating(key);
setError(null);
setSuccessMessage(null);
const updateData = { [key]: value };
const updated = await updateNotificationPreferences(updateData);
setPreferences(updated);
// Show success message
const prefName = key === 'emailNotificationsEnabled' ? 'Email'
: key === 'pushNotificationsEnabled' ? 'Push'
: 'In-App';
setSuccessMessage(`${prefName} notifications ${value ? 'enabled' : 'disabled'}`);
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err: any) {
console.error('[NotificationPreferences] Failed to update preference:', err);
setError(err.response?.data?.message || 'Failed to update notification preference');
// Revert the UI change
loadPreferences();
} finally {
setUpdating(null);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg">
<Settings className="h-6 w-6 text-white" />
</div>
<div>
<DialogTitle className="text-xl font-semibold">Notification Preferences</DialogTitle>
<DialogDescription className="text-sm">
Customize how you receive notifications for workflow updates
</DialogDescription>
</div>
</div>
</DialogHeader>
<Separator className="my-4" />
{loading ? (
<div className="p-12 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : (
<div className="space-y-6">
{/* Success Message */}
{successMessage && (
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-md animate-in fade-in slide-in-from-top-2">
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
<p className="text-sm text-green-800 font-medium">{successMessage}</p>
</div>
)}
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md animate-in fade-in slide-in-from-top-2">
<AlertCircle className="w-4 h-4 text-red-600 shrink-0" />
<p className="text-sm text-red-800 font-medium">{error}</p>
</div>
)}
{/* Email Notifications */}
<div className="space-y-2">
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border border-gray-200 hover:border-gray-300 transition-all">
<div className="flex items-center gap-4">
<div className="p-3 bg-white rounded-lg shadow-sm">
<Mail className="w-6 h-6 text-slate-600" />
</div>
<div>
<Label htmlFor="email-notifications-modal" className="text-base font-semibold text-gray-900 cursor-pointer">
Email Notifications
</Label>
<p className="text-sm text-gray-600 mt-1">
Receive important updates and alerts via email
</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'emailNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="email-notifications-modal"
checked={preferences.emailNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('emailNotificationsEnabled', checked)}
disabled={updating === 'emailNotificationsEnabled'}
/>
</div>
</div>
</div>
{/* Push Notifications */}
<div className="space-y-2">
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border border-gray-200 hover:border-gray-300 transition-all">
<div className="flex items-center gap-4">
<div className="p-3 bg-white rounded-lg shadow-sm">
<Bell className="w-6 h-6 text-slate-600" />
</div>
<div>
<Label htmlFor="push-notifications-modal" className="text-base font-semibold text-gray-900 cursor-pointer">
Push Notifications
</Label>
<p className="text-sm text-gray-600 mt-1">
Get instant browser notifications for real-time updates
</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'pushNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="push-notifications-modal"
checked={preferences.pushNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('pushNotificationsEnabled', checked)}
disabled={updating === 'pushNotificationsEnabled'}
/>
</div>
</div>
</div>
{/* In-App Notifications */}
<div className="space-y-2">
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border border-gray-200 hover:border-gray-300 transition-all">
<div className="flex items-center gap-4">
<div className="p-3 bg-white rounded-lg shadow-sm">
<MessageSquare className="w-6 h-6 text-slate-600" />
</div>
<div>
<Label htmlFor="inapp-notifications-modal" className="text-base font-semibold text-gray-900 cursor-pointer">
In-App Notifications
</Label>
<p className="text-sm text-gray-600 mt-1">
View notifications in the notification center
</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'inAppNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="inapp-notifications-modal"
checked={preferences.inAppNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('inAppNotificationsEnabled', checked)}
disabled={updating === 'inAppNotificationsEnabled'}
/>
</div>
</div>
</div>
<Separator />
{/* Info Section */}
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
<p className="text-sm text-gray-700 leading-relaxed">
<span className="font-semibold">Note:</span> These settings control your notification preferences across all channels.
Critical system alerts and urgent notifications may still be delivered regardless of these settings to ensure important information reaches you.
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,176 +0,0 @@
import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Bell, Mail, MessageSquare, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { getNotificationPreferences, updateNotificationPreferences, NotificationPreferences } from '@/services/userPreferenceApi';
export function NotificationPreferencesSimple() {
const [preferences, setPreferences] = useState<NotificationPreferences>({
emailNotificationsEnabled: true,
pushNotificationsEnabled: true,
inAppNotificationsEnabled: true
});
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadPreferences();
}, []);
const loadPreferences = async () => {
try {
setLoading(true);
setError(null);
const data = await getNotificationPreferences();
setPreferences(data);
} catch (err: any) {
console.error('[NotificationPreferences] Failed to load preferences:', err);
setError(err.response?.data?.message || 'Failed to load notification preferences');
} finally {
setLoading(false);
}
};
const handleToggle = async (key: keyof NotificationPreferences, value: boolean) => {
try {
setUpdating(key);
setError(null);
setSuccessMessage(null);
const updateData = { [key]: value };
const updated = await updateNotificationPreferences(updateData);
setPreferences(updated);
// Show success message
const prefName = key === 'emailNotificationsEnabled' ? 'Email'
: key === 'pushNotificationsEnabled' ? 'Push'
: 'In-App';
setSuccessMessage(`${prefName} notifications ${value ? 'enabled' : 'disabled'}`);
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err: any) {
console.error('[NotificationPreferences] Failed to update preference:', err);
setError(err.response?.data?.message || 'Failed to update notification preference');
// Revert the UI change
loadPreferences();
} finally {
setUpdating(null);
}
};
if (loading) {
return (
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-4">
{/* Success Message */}
{successMessage && (
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-md">
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
<p className="text-sm text-green-800 font-medium">{successMessage}</p>
</div>
)}
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md">
<AlertCircle className="w-4 h-4 text-red-600 shrink-0" />
<p className="text-sm text-red-800 font-medium">{error}</p>
</div>
)}
{/* Email Notifications */}
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm">
<Mail className="w-5 h-5 text-slate-600" />
</div>
<div>
<Label htmlFor="email-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
Email Notifications
</Label>
<p className="text-xs text-gray-600 mt-0.5">Receive notifications via email</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'emailNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="email-notifications"
checked={preferences.emailNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('emailNotificationsEnabled', checked)}
disabled={updating === 'emailNotificationsEnabled'}
/>
</div>
</div>
{/* Push Notifications */}
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm">
<Bell className="w-5 h-5 text-slate-600" />
</div>
<div>
<Label htmlFor="push-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
Push Notifications
</Label>
<p className="text-xs text-gray-600 mt-0.5">Receive browser push notifications</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'pushNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="push-notifications"
checked={preferences.pushNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('pushNotificationsEnabled', checked)}
disabled={updating === 'pushNotificationsEnabled'}
/>
</div>
</div>
{/* In-App Notifications */}
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm">
<MessageSquare className="w-5 h-5 text-slate-600" />
</div>
<div>
<Label htmlFor="inapp-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
In-App Notifications
</Label>
<p className="text-xs text-gray-600 mt-0.5">Show notifications within the application</p>
</div>
</div>
<div className="flex items-center gap-2">
{updating === 'inAppNotificationsEnabled' && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
)}
<Switch
id="inapp-notifications"
checked={preferences.inAppNotificationsEnabled}
onCheckedChange={(checked) => handleToggle('inAppNotificationsEnabled', checked)}
disabled={updating === 'inAppNotificationsEnabled'}
/>
</div>
</div>
<div className="pt-2 border-t border-gray-200">
<p className="text-xs text-gray-500 text-center">
These preferences control which notification channels you receive alerts through.
You'll still receive critical notifications regardless of these settings.
</p>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Bell, CheckCircle, XCircle } from 'lucide-react';
@ -23,11 +23,6 @@ export function NotificationStatusModal({
<Bell className="w-5 h-5 text-blue-600" />
Push Notifications
</DialogTitle>
<DialogDescription className="sr-only">
{success
? 'Push notifications have been successfully enabled for your account.'
: 'There was an error enabling push notifications. Please review the details below.'}
</DialogDescription>
</DialogHeader>
<div className="py-6">
@ -52,7 +47,7 @@ export function NotificationStatusModal({
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Subscription Failed
</h3>
<p className="text-sm text-gray-600 max-w-sm mb-4 whitespace-pre-line">
<p className="text-sm text-gray-600 max-w-sm mb-4">
{message || 'Unable to enable push notifications. Please check your browser settings and try again.'}
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-left w-full">

View File

@ -1,13 +1,10 @@
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
export interface SLAData {
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
percentageUsed?: number;
percent?: number; // Simplified format (alternative to percentageUsed)
status: 'normal' | 'approaching' | 'critical' | 'breached';
percentageUsed: number;
elapsedText: string;
elapsedHours: number;
remainingText: string;
@ -18,22 +15,16 @@ export interface SLAData {
interface SLAProgressBarProps {
sla: SLAData | null;
requestStatus: string;
isPaused?: boolean;
testId?: string;
}
export function SLAProgressBar({
sla,
requestStatus,
isPaused = false,
testId = 'sla-progress'
}: SLAProgressBarProps) {
// Pure presentational component - no business logic
// If request is closed/approved/rejected or no SLA data, show status message
// Check if SLA has required fields (percentageUsed or at least some data)
const hasValidSLA = sla && (sla.percentageUsed !== undefined || sla.elapsedHours !== undefined);
if (!hasValidSLA || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
return (
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
@ -49,119 +40,66 @@ export function SLAProgressBar({
);
}
// Use percentage-based colors to match approver SLA tracker
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
// Grey: When paused (frozen state)
const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : 0;
const rawStatus = sla.status || 'on_track';
// Determine colors based on percentage (matching ApprovalStepCard logic)
const getStatusColors = () => {
// If paused, use grey colors regardless of percentage
if (isPaused) {
return {
badge: 'bg-gray-500 text-white',
progress: 'bg-gray-500',
text: 'text-gray-600',
icon: 'text-gray-500'
};
}
if (percentageUsed >= 100) {
return {
badge: 'bg-red-600 text-white animate-pulse',
progress: 'bg-red-600',
text: 'text-red-600',
icon: 'text-blue-600'
};
} else if (percentageUsed >= 75) {
return {
badge: 'bg-orange-500 text-white',
progress: 'bg-orange-500',
text: 'text-orange-600',
icon: 'text-blue-600'
};
} else if (percentageUsed >= 50) {
return {
badge: 'bg-amber-500 text-white',
progress: 'bg-amber-500',
text: 'text-amber-600',
icon: 'text-blue-600'
};
} else {
return {
badge: 'bg-green-600 text-white',
progress: 'bg-green-600',
text: 'text-gray-700',
icon: 'text-blue-600'
};
}
};
const colors = getStatusColors();
// Normalize status for warning messages (still use status for text warnings)
const normalizedStatus = (rawStatus === 'on_track' || rawStatus === 'normal')
? 'normal'
: rawStatus;
return (
<div data-testid={testId}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{isPaused ? (
<Lock className={`h-4 w-4 ${colors.icon}`} />
) : (
<Clock className={`h-4 w-4 ${colors.icon}`} />
)}
<span className="text-sm font-semibold text-gray-900">
{isPaused ? 'SLA Progress (Paused)' : 'SLA Progress'}
</span>
<Clock className="h-4 w-4 text-blue-600" />
<span className="text-sm font-semibold text-gray-900">SLA Progress</span>
</div>
<Badge
className={`text-xs ${colors.badge}`}
className={`text-xs ${
sla.status === 'breached' ? 'bg-red-600 text-white animate-pulse' :
sla.status === 'critical' ? 'bg-orange-600 text-white' :
sla.status === 'approaching' ? 'bg-yellow-600 text-white' :
'bg-green-600 text-white'
}`}
data-testid={`${testId}-badge`}
>
{percentageUsed}% elapsed {isPaused && '(frozen)'}
{sla.percentageUsed || 0}% elapsed
</Badge>
</div>
<Progress
value={percentageUsed}
className="h-3 mb-2"
indicatorClassName={colors.progress}
value={sla.percentageUsed || 0}
className={`h-3 mb-2 ${
sla.status === 'breached' ? '[&>div]:bg-red-600' :
sla.status === 'critical' ? '[&>div]:bg-orange-600' :
sla.status === 'approaching' ? '[&>div]:bg-yellow-600' :
'[&>div]:bg-green-600'
}`}
data-testid={`${testId}-bar`}
/>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-600" data-testid={`${testId}-elapsed`}>
{formatHoursMinutes(sla.elapsedHours || 0)} elapsed
{sla.elapsedText || `${sla.elapsedHours || 0}h`} elapsed
</span>
<span
className={`font-semibold ${
normalizedStatus === 'breached' ? colors.text :
normalizedStatus === 'critical' ? colors.text :
sla.status === 'breached' ? 'text-red-600' :
sla.status === 'critical' ? 'text-orange-600' :
'text-gray-700'
}`}
data-testid={`${testId}-remaining`}
>
{formatHoursMinutes(sla.remainingHours || 0)} remaining
{sla.remainingText || `${sla.remainingHours || 0}h`} remaining
</span>
</div>
{sla.deadline && (
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
Due: {formatDateDDMMYYYY(sla.deadline, true)} {percentageUsed}% elapsed
Due: {new Date(sla.deadline).toLocaleString()} {sla.percentageUsed || 0}% elapsed
</p>
)}
{normalizedStatus === 'critical' && (
{sla.status === 'critical' && (
<p className="text-xs text-orange-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-critical`}>
<AlertTriangle className="h-3.5 w-3.5" />
Approaching Deadline
</p>
)}
{normalizedStatus === 'breached' && (
{sla.status === 'breached' && (
<p className="text-xs text-red-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-breached`}>
<AlertOctagon className="h-3.5 w-3.5" />
URGENT - Deadline Passed

View File

@ -22,14 +22,14 @@ export function SLATracker({ startDate, deadline, priority, className = '', show
const getProgressColor = () => {
if (slaStatus.progress >= 100) return 'bg-red-500';
if (slaStatus.progress >= 75) return 'bg-orange-500';
if (slaStatus.progress >= 50) return 'bg-amber-500'; // Using amber for better visibility
if (slaStatus.progress >= 50) return 'bg-yellow-500';
return 'bg-green-500';
};
const getStatusBadgeColor = () => {
if (slaStatus.progress >= 100) return 'bg-red-100 text-red-800 border-red-200';
if (slaStatus.progress >= 75) return 'bg-orange-100 text-orange-800 border-orange-200';
if (slaStatus.progress >= 50) return 'bg-amber-100 text-amber-800 border-amber-200'; // Using amber
if (slaStatus.progress >= 50) return 'bg-yellow-100 text-yellow-800 border-yellow-200';
return 'bg-green-100 text-green-800 border-green-200';
};

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
@ -11,7 +11,6 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { FilePreview } from '@/components/common/FilePreview';
import { ActionStatusModal } from '@/components/common/ActionStatusModal';
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
import { AddApproverModal } from '@/components/participant/AddApproverModal';
import { formatDateTime } from '@/utils/dateFormatter';
@ -73,17 +72,15 @@ interface Participant {
interface WorkNoteChatProps {
requestId: string;
onBack?: () => void;
messages?: any[]; // optional external messages
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
skipSocketJoin?: boolean; // Set to true when embedded in RequestDetail (to avoid double join)
requestTitle?: string; // Optional title for display
onAttachmentsExtracted?: (attachments: any[]) => void; // Callback to pass attachments to parent
isInitiator?: boolean; // Whether current user is the initiator
isSpectator?: boolean; // Whether current user is a spectator (view-only)
currentLevels?: any[]; // Current approval levels for add approver modal
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void; // Callback for policy violations
}
// All data is now fetched from backend - no hardcoded mock data
@ -109,20 +106,9 @@ const getStatusText = (status: string) => {
const formatMessage = (content: string) => {
// Enhanced mention highlighting - Blue color with extra bold font for high visibility
// Matches: @username or @FirstName LastName (only one space allowed for first name + last name)
// Pattern: @word or @word word (stops after second word)
// Matches: @test user11 or @Test User11 (any case, stops before next sentence/punctuation)
return content
.replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => {
const afterPos = offset + match.length;
const afterChar = string[afterPos];
// Valid mention if followed by: space, punctuation, @ (another mention), or end of string
if (!afterChar || /\s|[.,!?;:]|@/.test(afterChar)) {
return '<span class="inline-flex items-center px-2.5 py-0.5 rounded-md bg-blue-50 text-blue-800 font-black text-base border-2 border-blue-400 shadow-sm">@' + mention + '</span>';
}
return match;
})
.replace(/@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g, '<span class="inline-flex items-center px-2.5 py-0.5 rounded-md bg-blue-50 text-blue-800 font-black text-base border-2 border-blue-400 shadow-sm">@$1</span>')
.replace(/\n/g, '<br />');
};
@ -144,7 +130,7 @@ const FileIcon = ({ type }: { type: string }) => {
return <Paperclip className={`${iconClass} text-gray-600`} />;
};
export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover, maxApprovalLevels, onPolicyViolation }: WorkNoteChatProps) {
export function WorkNoteChat({ requestId, onBack, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, currentLevels = [], onAddApprover }: WorkNoteChatProps) {
const routeParams = useParams<{ requestId: string }>();
const effectiveRequestId = requestId || routeParams.requestId || '';
const [message, setMessage] = useState('');
@ -157,12 +143,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
const [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null);
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
const [showAddApproverModal, setShowAddApproverModal] = useState(false);
const [showActionStatusModal, setShowActionStatusModal] = useState(false);
const [actionStatus, setActionStatus] = useState<{ success: boolean; title: string; message: string }>({
success: true,
title: '',
message: ''
});
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const socketRef = useRef<any>(null);
@ -186,7 +166,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
errors: []
});
// Component render - logging removed for performance
console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current);
// Get request info (from props, all data comes from backend now)
const requestInfo = useMemo(() => {
@ -197,33 +177,20 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
}, [effectiveRequestId, requestTitle]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [loadingMessages, setLoadingMessages] = useState(false);
const onlineParticipants = participants.filter(p => p.status === 'online');
const filteredMessages = messages.filter(msg =>
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Determine if current user is a spectator (when not passed as prop)
const effectiveIsSpectator = useMemo(() => {
// If isSpectator is explicitly passed as prop, use it
if (isSpectator !== undefined) {
return isSpectator;
}
// Otherwise, determine from participants list
if (!currentUserId || participants.length === 0) {
return false;
}
return participants.some((p: any) => {
const pUserId = (p as any).userId || (p as any).user_id;
const pRole = (p.role || '').toString().toUpperCase();
const pType = ((p as any).participantType || (p as any).participant_type || '').toString().toUpperCase();
return pUserId === currentUserId && (pRole === 'SPECTATOR' || pType === 'SPECTATOR');
});
}, [isSpectator, currentUserId, participants]);
// Log when participants change - logging removed for performance
// Log when participants change
useEffect(() => {
// Participants state changed - logging removed
console.log('[WorkNoteChat] Participants state changed:', {
total: participants.length,
online: participants.filter(p => p.status === 'online').length,
participants: participants.map(p => ({ name: p.name, status: p.status, userId: (p as any).userId }))
});
}, [participants]);
// Load initial messages from backend (only if not provided by parent)
@ -233,6 +200,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
(async () => {
try {
setLoadingMessages(true);
const rows = await getWorkNotes(effectiveRequestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const noteUserId = m.userId || m.user_id;
@ -258,8 +226,11 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
};
}) : [];
setMessages(mapped as any);
console.log(`[WorkNoteChat] Loaded ${mapped.length} messages from backend`);
} catch (error) {
console.error('[WorkNoteChat] Failed to load messages:', error);
} finally {
setLoadingMessages(false);
}
})();
}, [effectiveRequestId, currentUserId, externalMessages]);
@ -340,19 +311,23 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
useEffect(() => {
// Skip if participants are already loaded (prevents resetting on tab switch)
if (participantsLoadedRef.current) {
console.log('[WorkNoteChat] Participants already loaded, skipping reload');
return;
}
if (!effectiveRequestId) {
console.log('[WorkNoteChat] No requestId, skipping participants load');
return;
}
(async () => {
try {
console.log('[WorkNoteChat] Fetching participants from backend...');
const details = await getWorkflowDetails(effectiveRequestId);
const rows = Array.isArray(details?.participants) ? details.participants : [];
if (rows.length === 0) {
console.log('[WorkNoteChat] No participants found in backend response');
return;
}
@ -370,7 +345,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
} as any;
});
// Participants loaded - logging removed
console.log('[WorkNoteChat] ✅ Loaded participants:', mapped.map(p => ({ name: p.name, userId: (p as any).userId })));
participantsLoadedRef.current = true;
setParticipants(mapped);
@ -379,7 +354,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
const maxRetries = 3;
const requestOnlineUsers = () => {
if (socketRef.current && socketRef.current.connected) {
// Requesting online users - logging removed
console.log('[WorkNoteChat] 📡 Requesting online users list (attempt', retryCount + 1, ')...');
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
retryCount++;
// Retry a few times to ensure we get the list
@ -387,7 +362,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
setTimeout(requestOnlineUsers, 500);
}
} else {
// Socket not ready - retrying silently
console.log('[WorkNoteChat] ⚠️ Socket not ready, will retry in 200ms... (attempt', retryCount + 1, ')');
retryCount++;
if (retryCount < maxRetries) {
setTimeout(requestOnlineUsers, 200);
@ -407,6 +382,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
return () => {
// Don't reset on unmount, only on request change
if (effectiveRequestId) {
console.log('[WorkNoteChat] Request changed, will reload participants on next mount');
participantsLoadedRef.current = false;
}
};
@ -430,7 +406,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
useEffect(() => {
const loadDocumentPolicy = async () => {
try {
const configs = await getPublicConfigurations('DOCUMENT_POLICY');
const configs = await getAllConfigurations('DOCUMENT_POLICY');
const configMap: Record<string, string> = {};
configs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
@ -466,42 +442,62 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
}
} catch {}
try {
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
// Get backend URL from environment (same as API calls)
// Strip /api/v1 suffix if present to get base WebSocket URL
const apiBaseUrl = (import.meta as any).env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
const base = apiBaseUrl.replace(/\/api\/v1$/, '');
console.log('[WorkNoteChat] Connecting socket to:', base);
const s = getSocket(base);
// Only join room if not skipped (standalone mode)
if (!skipSocketJoin) {
console.log('[WorkNoteChat] 🚪 About to join request room - requestId:', joinedId, 'userId:', currentUserId, 'socketId:', s.id);
joinRequestRoom(s, joinedId, currentUserId);
console.log('[WorkNoteChat] ✅ Emitted join:request event (standalone mode)');
// Mark self as online immediately after joining room
setParticipants(prev => {
const updated = prev.map(p =>
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
);
const selfParticipant = prev.find(p => (p as any).userId === currentUserId);
if (selfParticipant) {
console.log('[WorkNoteChat] 🟢 Marked self as online:', selfParticipant.name);
}
return updated;
});
} else {
console.log('[WorkNoteChat] ⏭️ Skipping socket join - parent component handling connection');
// Still mark self as online even when embedded (parent handles socket but we track presence)
setParticipants(prev => {
const updated = prev.map(p =>
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
);
const selfParticipant = prev.find(p => (p as any).userId === currentUserId);
if (selfParticipant) {
console.log('[WorkNoteChat] 🟢 Marked self as online (embedded mode):', selfParticipant.name);
}
return updated;
});
}
// Handle new work notes
const noteHandler = (payload: any) => {
console.log('[WorkNoteChat] 📨 Received worknote:new event:', payload);
const n = payload?.note || payload;
if (!n) {
console.log('[WorkNoteChat] ⚠️ No note data in payload');
return;
}
const noteId = n.noteId || n.id;
console.log('[WorkNoteChat] Processing note:', noteId, 'from:', n.userName || n.user_name);
// Prevent duplicates: check if message with same noteId already exists
setMessages(prev => {
if (prev.some(m => m.id === noteId)) {
console.log('[WorkNoteChat] ⏭️ Duplicate note, skipping:', noteId);
return prev; // Already exists, don't add
}
@ -531,96 +527,123 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
})) : undefined
} as any;
console.log('[WorkNoteChat] ✅ Adding new message to state:', newMessage.id);
return [...prev, newMessage];
});
};
// Handle presence: user joined
const presenceJoinHandler = (data: { userId: string; requestId: string }) => {
console.log('[WorkNoteChat] 🟢 presence:join received - userId:', data.userId, 'requestId:', data.requestId);
setParticipants(prev => {
if (prev.length === 0) {
console.log('[WorkNoteChat] ⚠️ Cannot update presence:join - no participants loaded yet');
return prev;
}
const participant = prev.find(p => (p as any).userId === data.userId);
if (!participant) {
console.log('[WorkNoteChat] ⚠️ User not found in participants list:', data.userId);
return prev;
}
const updated = prev.map(p =>
(p as any).userId === data.userId ? { ...p, status: 'online' as const } : p
);
console.log('[WorkNoteChat] ✅ Marked user as online:', participant.name, '- Total online:', updated.filter(p => p.status === 'online').length);
return updated;
});
};
// Handle presence: user left
const presenceLeaveHandler = (data: { userId: string; requestId: string }) => {
console.log('[WorkNoteChat] 🔴 presence:leave received - userId:', data.userId, 'requestId:', data.requestId);
// Never mark self as offline in own browser
if (data.userId === currentUserId) {
console.log('[WorkNoteChat] ⚠️ Ignoring presence:leave for self - staying online in own view');
return;
}
setParticipants(prev => {
if (prev.length === 0) {
console.log('[WorkNoteChat] ⚠️ Cannot update presence:leave - no participants loaded yet');
return prev;
}
const participant = prev.find(p => (p as any).userId === data.userId);
if (!participant) {
console.log('[WorkNoteChat] ⚠️ User not found in participants list:', data.userId);
return prev;
}
const updated = prev.map(p =>
(p as any).userId === data.userId ? { ...p, status: 'offline' as const } : p
);
console.log('[WorkNoteChat] ✅ Marked user as offline:', participant.name, '- Total online:', updated.filter(p => p.status === 'online').length);
return updated;
});
};
// Handle initial online users list
const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => {
console.log('[WorkNoteChat] 📋 presence:online received - requestId:', data.requestId, 'onlineUserIds:', data.userIds, 'count:', data.userIds.length);
setParticipants(prev => {
if (prev.length === 0) {
console.log('[WorkNoteChat] ⚠️ No participants loaded yet, cannot update online status. Response will be ignored.');
return prev;
}
// Updating online status - logging removed
console.log('[WorkNoteChat] 📊 Updating online status for', prev.length, 'participants');
const updated = prev.map(p => {
const pUserId = (p as any).userId || '';
const isCurrentUserSelf = pUserId === currentUserId;
// Always keep self as online in own browser
if (isCurrentUserSelf) {
console.log(`[WorkNoteChat] 🟢 ${p.name} (YOU - always online in own view)`);
return { ...p, status: 'online' as const };
}
const isOnline = data.userIds.includes(pUserId);
console.log(`[WorkNoteChat] ${isOnline ? '🟢' : '⚪'} ${p.name} (userId: ${pUserId.slice(0, 8)}...): ${isOnline ? 'ONLINE' : 'offline'}`);
return { ...p, status: isOnline ? 'online' as const : 'offline' as const };
});
// Online status updated - logging removed
const onlineCount = updated.filter(p => p.status === 'online').length;
console.log('[WorkNoteChat] ✅ Online status updated: ', onlineCount, '/', updated.length, 'participants online');
console.log('[WorkNoteChat] 📋 Online participants:', updated.filter(p => p.status === 'online').map(p => p.name).join(', '));
return updated;
});
};
// Handle socket reconnection
const connectHandler = () => {
console.log('[WorkNoteChat] 🔌 Socket connected/reconnected');
// Mark self as online on connection
setParticipants(prev => {
const updated = prev.map(p =>
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
);
const selfParticipant = prev.find(p => (p as any).userId === currentUserId);
if (selfParticipant) {
console.log('[WorkNoteChat] 🟢 Marked self as online on connect:', selfParticipant.name);
}
return updated;
});
// Rejoin room if needed
if (!skipSocketJoin) {
joinRequestRoom(s, joinedId, currentUserId);
console.log('[WorkNoteChat] 🔄 Rejoined request room on reconnection');
}
// Request online users on connection with multiple retries
if (participantsLoadedRef.current) {
console.log('[WorkNoteChat] 📡 Requesting online users after connection...');
s.emit('request:online-users', { requestId: joinedId });
// Send additional requests with delay to ensure we get the response
setTimeout(() => s.emit('request:online-users', { requestId: joinedId }), 300);
setTimeout(() => s.emit('request:online-users', { requestId: joinedId }), 800);
} else {
console.log('[WorkNoteChat] ⏳ Participants not loaded yet, will request online users when they load');
}
};
@ -638,13 +661,13 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
};
// Debug: Log ALL events received from server for this request
const anyEventHandler = (eventName: string) => {
const anyEventHandler = (eventName: string, ...args: any[]) => {
if (eventName.includes('presence') || eventName.includes('worknote') || eventName.includes('request')) {
// Socket event received - logging removed
console.log('[WorkNoteChat] 📨 Event received:', eventName, args);
}
};
// Attaching socket listeners - logging removed
console.log('[WorkNoteChat] 🔌 Attaching socket listeners for request:', joinedId);
s.on('connect', connectHandler);
s.on('disconnect', disconnectHandler);
s.on('error', errorHandler);
@ -653,26 +676,35 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
s.on('presence:leave', presenceLeaveHandler);
s.on('presence:online', presenceOnlineHandler);
s.onAny(anyEventHandler); // Debug: catch all events
// Socket listeners attached - logging removed
console.log('[WorkNoteChat] ✅ All socket listeners attached (including error handlers)');
// Store socket in ref for coordination with participants loading
socketRef.current = s;
// Always request online users after socket is ready
console.log('[WorkNoteChat] 🔌 Socket ready and listeners attached, socket.connected:', s.connected);
if (s.connected) {
if (participantsLoadedRef.current) {
// Requesting online users with retries - logging removed
console.log('[WorkNoteChat] 📡 Participants already loaded, requesting online users now (with retries)');
// Send multiple requests to ensure we get the response
s.emit('request:online-users', { requestId: joinedId });
setTimeout(() => {
console.log('[WorkNoteChat] 📡 Retry 1: Requesting online users...');
s.emit('request:online-users', { requestId: joinedId });
}, 300);
setTimeout(() => {
console.log('[WorkNoteChat] 📡 Retry 2: Requesting online users...');
s.emit('request:online-users', { requestId: joinedId });
}, 800);
setTimeout(() => {
console.log('[WorkNoteChat] 📡 Final retry: Requesting online users...');
s.emit('request:online-users', { requestId: joinedId });
}, 1500);
} else {
console.log('[WorkNoteChat] ⏳ Waiting for participants to load first...');
}
} else {
console.log('[WorkNoteChat] ⏳ Socket not connected yet, will request online users on connect event');
}
// cleanup
@ -688,9 +720,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
// Only leave room if we joined it
if (!skipSocketJoin) {
leaveRequestRoom(s, joinedId);
console.log('[WorkNoteChat] 🚪 Emitting leave:request for room (standalone mode)');
}
socketRef.current = null;
// Socket cleanup completed - logging removed
console.log('[WorkNoteChat] 🧹 Cleaned up all socket listeners and left room');
};
(window as any).__wn_cleanup = cleanup;
} catch {}
@ -711,11 +744,16 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
const participant = participants.find(p =>
p.name.toLowerCase().includes(mentionedName.toLowerCase())
);
console.log('[Mention Match] Looking for:', mentionedName, 'Found participant:', participant ? `${participant.name} (${(participant as any)?.userId})` : 'NOT FOUND');
return (participant as any)?.userId;
})
.filter(Boolean);
// Message sending - logging removed
console.log('[WorkNoteChat] 📝 MESSAGE:', message);
console.log('[WorkNoteChat] 👥 ALL PARTICIPANTS:', participants.map(p => ({ name: p.name, userId: (p as any)?.userId })));
console.log('[WorkNoteChat] 🎯 MENTIONS EXTRACTED:', mentions);
console.log('[WorkNoteChat] 🆔 USER IDS FOUND:', mentionedUserIds);
console.log('[WorkNoteChat] 📤 SENDING TO BACKEND:', { message, mentions: mentionedUserIds });
const attachments = selectedFiles.map(file => ({
name: file.name,
@ -849,7 +887,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Messages mapped - logging removed
console.log('[WorkNoteChat] Mapped and sorted messages:', sorted.length, 'total');
setMessages(sorted);
} catch (err) {
console.error('[WorkNoteChat] Error mapping messages:', err);
@ -859,13 +897,20 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
(async () => {
try {
const rows = await getWorkNotes(effectiveRequestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; // Get role directly from backend
const participantRole = getFormattedRole(userRole);
const noteUserId = m.userId || m.user_id;
console.log('[WorkNoteChat] Loaded work notes from backend:', rows);
return {
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; // Get role directly from backend
const participantRole = getFormattedRole(userRole);
const noteUserId = m.userId || m.user_id;
console.log('[WorkNoteChat] Mapping note:', {
rawNote: m,
extracted: { userName, userRole, participantRole }
});
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
@ -1010,26 +1055,15 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
// Request updated online users list from server to get correct status
if (socketRef.current && socketRef.current.connected) {
console.log('[WorkNoteChat] 📡 Requesting online users after adding spectator...');
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
}
}
setShowAddSpectatorModal(false);
// Show success modal
setActionStatus({
success: true,
title: 'Spectator Added',
message: 'Spectator added successfully. They can now view this request.'
});
setShowActionStatusModal(true);
alert('Spectator added successfully');
} catch (error: any) {
console.error('Failed to add spectator:', error);
// Show error modal
setActionStatus({
success: false,
title: 'Failed to Add Spectator',
message: error?.response?.data?.error || 'Failed to add spectator. Please try again.'
});
setShowActionStatusModal(true);
alert(error?.response?.data?.error || 'Failed to add spectator');
throw error;
}
};
@ -1073,22 +1107,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
}
}
setShowAddApproverModal(false);
// Show success modal
setActionStatus({
success: true,
title: 'Approver Added',
message: `Approver added successfully at Level ${level} with ${tatHours}h TAT`
});
setShowActionStatusModal(true);
alert(`Approver added successfully at Level ${level} with ${tatHours}h TAT`);
} catch (error: any) {
console.error('Failed to add approver:', error);
// Show error modal
setActionStatus({
success: false,
title: 'Failed to Add Approver',
message: error?.response?.data?.error || 'Failed to add approver. Please try again.'
});
setShowActionStatusModal(true);
alert(error?.response?.data?.error || 'Failed to add approver');
throw error;
}
}
@ -1145,23 +1167,15 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
const extractMentions = (text: string): string[] => {
// Use the SAME regex pattern as formatMessage to ensure consistency
// Only one space allowed: @word or @word word (first name + last name)
const mentionRegex = /@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g;
const mentionRegex = /@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g;
const mentions: string[] = [];
let match;
while ((match = mentionRegex.exec(text)) !== null) {
if (match[1]) {
// Check if this is a valid mention (followed by space, punctuation, @, or end)
const afterPos = match.index + match[0].length;
const afterText = text.slice(afterPos);
const afterChar = text[afterPos];
// Valid if followed by: @ (another mention), space, punctuation, or end
if (afterText.startsWith('@') || !afterChar || /\s|[.,!?;:]|@/.test(afterChar)) {
mentions.push(match[1].trim());
}
mentions.push(match[1].trim());
}
}
console.log('[Extract Mentions] Found:', mentions, 'from text:', text);
return mentions;
};
@ -1386,14 +1400,14 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
e.stopPropagation();
if (!attachmentId) {
toast.error('Cannot download: Attachment ID missing');
alert('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
toast.error('Failed to download file');
alert('Failed to download file');
}
}}
title="Download file"
@ -1494,43 +1508,34 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
<div className="relative mb-2">
{/* Mention Suggestions Dropdown - Shows above textarea */}
{(() => {
// Find the last @ symbol that hasn't been completed (doesn't have a space after a name)
const lastAtIndex = message.lastIndexOf('@');
const hasAt = lastAtIndex >= 0;
const textAfterAt = hasAt ? message.slice(lastAtIndex + 1) : '';
if (!hasAt) return null;
// Get text after the last @
const textAfterAt = message.slice(lastAtIndex + 1);
// Check if this mention is already completed
// A completed mention looks like: "@username " (ends with space after name)
// An incomplete mention looks like: "@" or "@user" (no space after, or just typed @)
const trimmedAfterAt = textAfterAt.trim();
// Don't show if:
// 1. No @ found
// 2. Text after @ is too long (>20 chars)
// 3. Text after @ ends with a space (completed mention)
// 4. Text after @ contains a space (already selected a user)
const endsWithSpace = textAfterAt.endsWith(' ');
const hasNonSpaceChars = trimmedAfterAt.length > 0;
// Don't show dropdown if:
// 1. Text after @ is too long (>20 chars) - probably not a mention
// 2. Text after @ ends with space AND has characters (completed mention like "@user ")
// 3. Text after @ contains space in the middle (like "@user name" - multi-word name already typed)
const containsSpaceInMiddle = trimmedAfterAt.includes(' ') && !endsWithSpace;
const isCompletedMention = endsWithSpace && hasNonSpaceChars;
// Show dropdown if:
// - Has @ symbol
// - Text after @ is not too long
// - Mention is not completed (doesn't end with space after a name)
// - Doesn't contain space in middle (not a multi-word name being typed)
const containsSpace = textAfterAt.trim().includes(' ');
const shouldShowDropdown = hasAt &&
textAfterAt.length <= 20 &&
!containsSpaceInMiddle &&
!isCompletedMention;
!endsWithSpace &&
!containsSpace;
console.log('[Mention Debug]', {
hasAt,
textAfterAt: `"${textAfterAt}"`,
endsWithSpace,
containsSpace,
shouldShowDropdown,
participantsCount: participants.length
});
if (!shouldShowDropdown) return null;
// Use trimmed text for search (ignore trailing spaces)
const searchTerm = trimmedAfterAt.toLowerCase();
const searchTerm = textAfterAt.toLowerCase();
const filteredParticipants = participants.filter(p => {
// Exclude current user from mention suggestions
const isCurrentUserInList = (p as any).userId === currentUserId;
@ -1543,6 +1548,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
return true; // Show all if no search term
});
console.log('[Mention Debug] Filtered participants:', filteredParticipants.length);
return (
<div className="absolute bottom-full left-0 mb-2 bg-white border-2 border-blue-300 rounded-lg shadow-2xl p-3 z-[100] w-full sm:max-w-md">
<p className="text-sm font-semibold text-gray-900 mb-2">💬 Mention someone</p>
@ -1555,10 +1562,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Find the last @ and replace everything from @ to end with the new mention
const lastAt = message.lastIndexOf('@');
const before = message.slice(0, lastAt);
// Add the mention with a space after for easy continuation
setMessage(before + '@' + participant.name + ' ');
}}
className="w-full flex items-center gap-3 p-3 hover:bg-blue-50 rounded-lg text-left transition-colors border border-transparent hover:border-blue-200"
@ -1741,43 +1746,40 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
</div>
</div>
{/* Quick Actions Section - Hide for spectators */}
{!effectiveIsSpectator && (
<div className="p-4 sm:p-6 flex-shrink-0">
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
<div className="space-y-2">
{/* Only initiator can add approvers */}
{isInitiator && (
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 h-9 text-sm"
onClick={() => setShowAddApproverModal(true)}
>
<UserPlus className="h-4 w-4" />
Add Approver
</Button>
)}
<div className="p-4 sm:p-6 flex-shrink-0">
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
<div className="space-y-2">
{/* Only initiator can add approvers */}
{isInitiator && (
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 h-9 text-sm"
onClick={() => setShowAddSpectatorModal(true)}
onClick={() => setShowAddApproverModal(true)}
>
<Eye className="h-4 w-4" />
Add Spectator
<UserPlus className="h-4 w-4" />
Add Approver
</Button>
{/* <Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Bell className="h-4 w-4" />
Manage Notifications
</Button>
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Archive className="h-4 w-4" />
Archive Chat
</Button> */}
</div>
)}
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 h-9 text-sm"
onClick={() => setShowAddSpectatorModal(true)}
>
<Eye className="h-4 w-4" />
Add Spectator
</Button>
{/* <Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Bell className="h-4 w-4" />
Manage Notifications
</Button>
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Archive className="h-4 w-4" />
Archive Chat
</Button> */}
</div>
)}
</div>
</div>
</div>
@ -1795,20 +1797,18 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
/>
)}
{/* Add Spectator Modal - Hide for spectators */}
{!effectiveIsSpectator && (
<AddSpectatorModal
open={showAddSpectatorModal}
onClose={() => setShowAddSpectatorModal(false)}
onConfirm={handleAddSpectator}
requestIdDisplay={effectiveRequestId}
requestTitle={requestInfo.title}
existingParticipants={existingParticipants}
/>
)}
{/* Add Spectator Modal */}
<AddSpectatorModal
open={showAddSpectatorModal}
onClose={() => setShowAddSpectatorModal(false)}
onConfirm={handleAddSpectator}
requestIdDisplay={effectiveRequestId}
requestTitle={requestInfo.title}
existingParticipants={existingParticipants}
/>
{/* Add Approver Modal - Hide for spectators */}
{!effectiveIsSpectator && isInitiator && (
{/* Add Approver Modal */}
{isInitiator && (
<AddApproverModal
open={showAddApproverModal}
onClose={() => setShowAddApproverModal(false)}
@ -1817,8 +1817,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
requestTitle={requestInfo.title}
existingParticipants={existingParticipants}
currentLevels={currentLevels}
maxApprovalLevels={maxApprovalLevels}
onPolicyViolation={onPolicyViolation}
/>
)}
@ -1863,15 +1861,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
</DialogFooter>
</DialogContent>
</Dialog>
{/* Action Status Modal - Success/Error feedback for adding approver/spectator */}
<ActionStatusModal
open={showActionStatusModal}
onClose={() => setShowActionStatusModal(false)}
success={actionStatus.success}
title={actionStatus.title}
message={actionStatus.message}
/>
</div>
);
}

View File

@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
import { toast } from 'sonner';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { formatDateTime } from '@/utils/dateFormatter';
import { FilePreview } from '@/components/common/FilePreview';
@ -54,6 +53,7 @@ interface Message {
interface WorkNoteChatSimpleProps {
requestId: string;
messages?: any[];
loading?: boolean;
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
}
@ -91,7 +91,7 @@ const formatParticipantRole = (role: string | undefined): string => {
}
};
export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSend }: WorkNoteChatSimpleProps) {
export function WorkNoteChatSimple({ requestId, messages: externalMessages, loading, onSend }: WorkNoteChatSimpleProps) {
const [message, setMessage] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
@ -142,8 +142,8 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
}
} catch {}
try {
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
const base = (import.meta as any).env.VITE_BASE_URL || window.location.origin;
const s = getSocket(base);
joinRequestRoom(s, joinedId, currentUserId || undefined);
@ -151,8 +151,11 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const n = payload?.note || payload;
if (!n) return;
console.log('[WorkNoteChat] Received note via socket:', n);
setMessages(prev => {
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
console.log('[WorkNoteChat] Duplicate detected, skipping');
return prev;
}
@ -173,6 +176,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
isCurrentUser: noteUserId === currentUserId
} as any;
console.log('[WorkNoteChat] Adding new message:', newMsg);
return [...prev, newMsg];
});
};
@ -262,6 +266,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
(async () => {
try {
const rows = await getWorkNotes(requestId);
console.log('[WorkNoteChat] Loaded work notes:', rows);
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User';
@ -324,6 +329,18 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
'🚀', '🎯', '🔍', '🔔', '💡'
];
const extractMentions = (text: string): string[] => {
const mentionRegex = /@([\w\s]+)(?=\s|$|[.,!?])/g;
const mentions: string[] = [];
let match;
while ((match = mentionRegex.exec(text)) !== null) {
if (match[1]) {
mentions.push(match[1].trim());
}
}
return mentions;
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@ -500,14 +517,14 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
e.stopPropagation();
if (!attachmentId) {
toast.error('Cannot download: Attachment ID missing');
alert('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
toast.error('Failed to download file');
alert('Failed to download file');
}
}}
title="Download file"

View File

@ -1,16 +1,9 @@
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon, FileEdit } from 'lucide-react';
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { updateBreachReason as updateBreachReasonApi } from '@/services/workflowApi';
import { toast } from 'sonner';
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon } from 'lucide-react';
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
export interface ApprovalStep {
step: number;
@ -39,9 +32,7 @@ interface ApprovalStepCardProps {
approval?: any; // Raw approval data from backend
isCurrentUser?: boolean;
isInitiator?: boolean;
isCurrentLevel?: boolean; // Whether this step is the current active level
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
testId?: string;
}
@ -49,12 +40,12 @@ interface ApprovalStepCardProps {
const formatWorkingHours = (hours: number): string => {
const WORKING_HOURS_PER_DAY = 8;
if (hours < WORKING_HOURS_PER_DAY) {
return formatHoursMinutes(hours);
return `${hours.toFixed(1)}h`;
}
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
const remainingHours = hours % WORKING_HOURS_PER_DAY;
if (remainingHours > 0) {
return `${days}d ${formatHoursMinutes(remainingHours)}`;
return `${days}d ${remainingHours.toFixed(1)}h`;
}
return `${days}d`;
};
@ -67,8 +58,6 @@ const getStepIcon = (status: string, isSkipped?: boolean) => {
return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />;
case 'rejected':
return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />;
case 'paused':
return <PauseCircle className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600" />;
case 'pending':
case 'in-review':
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
@ -85,86 +74,18 @@ export function ApprovalStepCard({
approval,
isCurrentUser = false,
isInitiator = false,
isCurrentLevel = false,
onSkipApprover,
onRefresh,
testId = 'approval-step'
}: ApprovalStepCardProps) {
const { user } = useAuth();
const [showBreachReasonModal, setShowBreachReasonModal] = useState(false);
const [breachReason, setBreachReason] = useState('');
const [savingReason, setSavingReason] = useState(false);
// Get existing breach reason from approval or step data
const existingBreachReason = (approval as any)?.breachReason || (step as any)?.breachReason || '';
// Reset modal state when it closes
useEffect(() => {
if (!showBreachReasonModal) {
setBreachReason('');
}
}, [showBreachReasonModal]);
const isActive = step.status === 'pending' || step.status === 'in-review';
const isCompleted = step.status === 'approved';
const isRejected = step.status === 'rejected';
const isWaiting = step.status === 'waiting';
const isPaused = step.status === 'paused';
const tatHours = Number(step.tatHours || 0);
const actualHours = step.actualHours ?? 0;
const actualHours = step.actualHours;
const savedHours = isCompleted && actualHours ? Math.max(0, tatHours - actualHours) : 0;
// Calculate if breached
const progressPercentage = tatHours > 0 ? (actualHours / tatHours) * 100 : 0;
const isBreached = progressPercentage >= 100;
// Check permissions: ADMIN, MANAGEMENT, or the approver
const isAdmin = user?.role === 'ADMIN';
const isManagement = hasManagementAccess(user);
const isApprover = step.approverId === user?.userId;
const canEditBreachReason = isAdmin || isManagement || isApprover;
const handleSaveBreachReason = async () => {
if (!breachReason.trim()) {
toast.error('Breach Reason Required', {
description: 'Please enter a reason for the breach.',
});
return;
}
setSavingReason(true);
try {
await updateBreachReasonApi(step.levelId, breachReason.trim());
setShowBreachReasonModal(false);
setBreachReason('');
toast.success('Breach Reason Updated', {
description: 'The breach reason has been saved and will appear in the TAT Breach Report.',
duration: 5000,
});
// Refresh data if callback provided, otherwise reload page
if (onRefresh) {
await onRefresh();
} else {
// Fallback to page reload if no refresh callback
setTimeout(() => {
window.location.reload();
}, 1000);
}
} catch (error: any) {
console.error('Error updating breach reason:', error);
const errorMessage = error?.response?.data?.error || error?.message || 'Failed to update breach reason. Please try again.';
toast.error('Failed to Update Breach Reason', {
description: errorMessage,
duration: 5000,
});
} finally {
setSavingReason(false);
}
};
return (
<div
className={`relative p-3 sm:p-4 md:p-5 rounded-lg border-2 transition-all ${
@ -185,7 +106,6 @@ export function ApprovalStepCard({
<div className="flex items-start gap-2 sm:gap-3 md:gap-4">
<div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
step.isSkipped ? 'bg-orange-100' :
isPaused ? 'bg-yellow-100' :
isActive ? 'bg-blue-100' :
isCompleted ? 'bg-green-100' :
isRejected ? 'bg-red-100' :
@ -269,62 +189,21 @@ export function ApprovalStepCard({
{(() => {
// Calculate actual progress percentage based on time used
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
const displayPercentage = Math.min(100, progressPercentage);
// Determine progress bar color based on percentage
// Green: 0-50%, Yellow: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
const getIndicatorColor = () => {
if (isBreached) return 'bg-red-600';
if (progressPercentage >= 75) return 'bg-orange-500';
if (progressPercentage >= 50) return 'bg-amber-500'; // Using amber instead of yellow for better visibility
return 'bg-green-600';
};
const getProgressTextColor = () => {
if (isBreached) return 'text-red-600';
if (progressPercentage >= 75) return 'text-orange-600';
if (progressPercentage >= 50) return 'text-amber-600';
return 'text-green-600';
};
const progressPercentage = tatHours > 0 ? Math.min(100, (actualHours / tatHours) * 100) : 0;
return (
<>
<Progress
value={displayPercentage}
value={progressPercentage}
className="h-2 bg-gray-200"
indicatorClassName={getIndicatorColor()}
data-testid={`${testId}-progress-bar`}
/>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className={`font-semibold ${getProgressTextColor()}`}>
{Math.round(displayPercentage)}% of TAT used
</span>
{isBreached && canEditBreachReason && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => {
setBreachReason(existingBreachReason);
setShowBreachReasonModal(true);
}}
>
<FileEdit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<span className="text-green-600 font-semibold">
{progressPercentage.toFixed(1)}% of TAT used
</span>
{savedHours > 0 && (
<span className="text-green-600 font-semibold">Saved {formatHoursMinutes(savedHours)}</span>
<span className="text-green-600 font-semibold">Saved {savedHours.toFixed(1)} hours</span>
)}
</div>
</>
@ -332,17 +211,6 @@ export function ApprovalStepCard({
})()}
</div>
{/* Breach Reason Display for Completed Approver */}
{isBreached && existingBreachReason && (
<div className="mt-4 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
<FileEdit className="w-3.5 h-3.5" />
Breach Reason:
</p>
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{existingBreachReason}</p>
</div>
)}
{/* Conclusion Remark */}
{step.comment && (
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
@ -356,28 +224,25 @@ export function ApprovalStepCard({
</div>
)}
{/* Active Approver (including paused) - Show Real-time Progress from Backend */}
{/* Only show SLA for the current level step, not future levels */}
{isCurrentLevel && (isActive || isPaused) && approval?.sla && (
{/* Active Approver - Show Real-time Progress from Backend */}
{isActive && approval?.sla && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600">Due by:</span>
<span className="font-medium text-gray-900">
{approval.sla.deadline ? formatDateDDMMYYYY(approval.sla.deadline, true) : 'Not set'}
{approval.sla.deadline ? formatDateTime(approval.sla.deadline) : 'Not set'}
</span>
</div>
{/* Current Approver - Time Tracking */}
<div className={`border rounded-lg p-3 ${
isPaused ? 'bg-gray-100 border-gray-300' :
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
'bg-green-50 border-green-200'
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
'bg-yellow-50 border-yellow-200'
}`}>
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
Current Approver - Time Tracking {isPaused && '(Paused)'}
Current Approver - Time Tracking
</p>
<div className="space-y-2 text-xs mb-3">
@ -387,90 +252,38 @@ export function ApprovalStepCard({
</div>
<div className="flex justify-between">
<span className="text-gray-600">Time used:</span>
<span className="font-medium text-gray-900">{approval.sla.elapsedText} / {formatHoursMinutes(tatHours)} allocated</span>
<span className="font-medium text-gray-900">{approval.sla.elapsedText} / {tatHours}h allocated</span>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
{(() => {
// Determine color based on percentage used
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
// Grey: When paused (frozen state)
const percentUsed = approval.sla.percentageUsed || 0;
const getActiveIndicatorColor = () => {
if (isPaused) return 'bg-gray-500'; // Grey when paused
if (percentUsed >= 100) return 'bg-red-600';
if (percentUsed >= 75) return 'bg-orange-500';
if (percentUsed >= 50) return 'bg-amber-500';
return 'bg-green-600';
};
const getActiveTextColor = () => {
if (isPaused) return 'text-gray-600'; // Grey when paused
if (percentUsed >= 100) return 'text-red-600';
if (percentUsed >= 75) return 'text-orange-600';
if (percentUsed >= 50) return 'text-amber-600';
return 'text-green-600';
};
return (
<>
<Progress
value={approval.sla.percentageUsed}
className="h-3"
indicatorClassName={getActiveIndicatorColor()}
data-testid={`${testId}-sla-progress`}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold ${getActiveTextColor()}`}>
Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used
</span>
{approval.sla.status === 'breached' && canEditBreachReason && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => {
setBreachReason(existingBreachReason);
setShowBreachReasonModal(true);
}}
>
<FileEdit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<span className="text-xs font-medium text-gray-700">
{approval.sla.remainingText} remaining
</span>
</div>
</>
);
})()}
<Progress
value={approval.sla.percentageUsed}
className={`h-3 ${
approval.sla.status === 'breached' ? '[&>div]:bg-red-600' :
approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' :
'[&>div]:bg-yellow-600'
}`}
data-testid={`${testId}-sla-progress`}
/>
<div className="flex items-center justify-between">
<span className={`text-xs font-semibold ${
approval.sla.status === 'breached' ? 'text-red-600' :
approval.sla.status === 'critical' ? 'text-orange-600' :
'text-yellow-700'
}`}>
Progress: {approval.sla.percentageUsed}% of TAT used
</span>
<span className="text-xs font-medium text-gray-700">
{approval.sla.remainingText} remaining
</span>
</div>
{approval.sla.status === 'breached' && (
<>
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
<AlertOctagon className="w-4 h-4" />
Deadline Breached
</p>
{existingBreachReason && (
<div className="mt-3 p-3 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
<FileEdit className="w-3.5 h-3.5" />
Breach Reason:
</p>
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{existingBreachReason}</p>
</div>
)}
</>
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
<AlertOctagon className="w-4 h-4" />
Deadline Breached
</p>
)}
{approval.sla.status === 'critical' && (
<p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5">
@ -575,13 +388,13 @@ export function ApprovalStepCard({
<div className="bg-white/50 rounded px-2 py-1">
<span className="text-gray-500">Allocated:</span>
<span className="ml-1 font-medium text-gray-900">
{formatHoursMinutes(Number(alert.tatHoursAllocated || 0))}
{Number(alert.tatHoursAllocated || 0).toFixed(2)}h
</span>
</div>
<div className="bg-white/50 rounded px-2 py-1">
<span className="text-gray-500">Elapsed:</span>
<span className="ml-1 font-medium text-gray-900">
{formatHoursMinutes(Number(alert.tatHoursElapsed || 0))}
{Number(alert.tatHoursElapsed || 0).toFixed(2)}h
{alert.metadata?.tatTestMode && (
<span className="text-purple-600 ml-1">
({(Number(alert.tatHoursElapsed || 0) * 60).toFixed(0)}m)
@ -594,7 +407,7 @@ export function ApprovalStepCard({
<span className={`ml-1 font-medium ${
(alert.tatHoursRemaining || 0) < 2 ? 'text-red-600' : 'text-gray-900'
}`}>
{formatHoursMinutes(Number(alert.tatHoursRemaining || 0))}
{Number(alert.tatHoursRemaining || 0).toFixed(2)}h
{alert.metadata?.tatTestMode && (
<span className="text-purple-600 ml-1">
({(Number(alert.tatHoursRemaining || 0) * 60).toFixed(0)}m)
@ -605,7 +418,7 @@ export function ApprovalStepCard({
<div className="bg-white/50 rounded px-2 py-1">
<span className="text-gray-500">Due by:</span>
<span className="ml-1 font-medium text-gray-900">
{alert.expectedCompletionTime ? formatDateDDMMYYYY(alert.expectedCompletionTime, true) : 'N/A'}
{alert.expectedCompletionTime ? formatDateShort(alert.expectedCompletionTime) : 'N/A'}
</span>
</div>
</div>
@ -643,9 +456,8 @@ export function ApprovalStepCard({
</p>
)}
{/* Skip Approver Button - Only show for initiator on pending/in-review levels (not when paused) */}
{/* User must resume first before skipping */}
{isInitiator && !isPaused && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
{/* Skip Approver Button - Only show for initiator on pending/in-review levels */}
{isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
<Button
variant="outline"
@ -662,60 +474,12 @@ export function ApprovalStepCard({
Skip This Approver
</Button>
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
{isPaused
? 'Skip this approver to resume the workflow and move to next level'
: 'Skip if approver is unavailable and move to next level'
}
Skip if approver is unavailable and move to next level
</p>
</div>
)}
</div>
</div>
{/* Breach Reason Modal */}
<Dialog open={showBreachReasonModal} onOpenChange={setShowBreachReasonModal}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{existingBreachReason ? 'Edit Breach Reason' : 'Add Breach Reason'}</DialogTitle>
<DialogDescription>
{existingBreachReason
? 'Update the reason for the TAT breach. This will be reflected in the TAT Breach Report.'
: 'Please provide a reason for the TAT breach. This will be reflected in the TAT Breach Report.'}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
placeholder="Enter the reason for the breach..."
value={breachReason}
onChange={(e) => setBreachReason(e.target.value)}
className="min-h-[100px]"
maxLength={500}
/>
<p className="text-xs text-gray-500 mt-2">
{breachReason.length}/500 characters
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowBreachReasonModal(false);
setBreachReason('');
}}
disabled={savingReason}
>
Cancel
</Button>
<Button
onClick={handleSaveBreachReason}
disabled={!breachReason.trim() || savingReason}
className="bg-red-600 hover:bg-red-700"
>
{savingReason ? 'Saving...' : 'Save Reason'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,795 @@
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 {
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: {claim.slaEndDate} {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) => {
console.log('Dealer documents submitted:', 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) => {
console.log('Verification data:', data);
toast.success('Verification Complete', {
description: `Amount set to ${data.approvedAmount}. E-invoice will be generated.`,
});
setInitiatorVerificationModal(false);
}}
activityName={claim.claimDetails?.activityName || claim.title}
requestedAmount={claim.claimDetails?.estimatedBudget || claim.amount || 'TBD'}
documents={claim.documents || []}
/>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -1,535 +0,0 @@
import { motion } from 'framer-motion';
import { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
import { FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
import { useMultiUserSearch } from '@/hooks/useUserSearch';
import { ensureUserExists } from '@/services/userApi';
interface ApprovalWorkflowStepProps {
formData: FormData;
updateFormData: (field: keyof FormData, value: any) => void;
onValidationError: (error: { type: string; email: string; message: string }) => void;
systemPolicy: SystemPolicy;
onPolicyViolation: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
}
/**
* Component: ApprovalWorkflowStep
*
* Purpose: Step 3 - Approval workflow configuration
*
* Features:
* - Configure number of approvers
* - Define approval hierarchy with TAT
* - User search with @ prefix
* - Test IDs for testing
*
* Note: This is a simplified version. Full implementation includes complex approver management.
*/
export function ApprovalWorkflowStep({
formData,
updateFormData,
onValidationError,
systemPolicy,
onPolicyViolation
}: ApprovalWorkflowStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
// Initialize approvers array when approverCount changes - moved from render to useEffect
useEffect(() => {
const approverCount = formData.approverCount || 1;
const currentApprovers = formData.approvers || [];
// Ensure we have the correct number of approvers
if (currentApprovers.length < approverCount) {
const newApprovers = [...currentApprovers];
// Fill missing approver slots
for (let i = currentApprovers.length; i < approverCount; i++) {
if (!newApprovers[i]) {
newApprovers[i] = {
email: '',
name: '',
level: i + 1,
tat: '' as any
};
}
}
updateFormData('approvers', newApprovers);
} else if (currentApprovers.length > approverCount) {
// Trim excess approvers if count was reduced
updateFormData('approvers', currentApprovers.slice(0, approverCount));
}
}, [formData.approverCount, updateFormData]);
const handleApproverEmailChange = (index: number, value: string) => {
const newApprovers = [...formData.approvers];
const previousEmail = newApprovers[index]?.email;
const emailChanged = previousEmail !== value;
newApprovers[index] = {
...newApprovers[index],
email: value,
level: index + 1,
userId: emailChanged ? undefined : newApprovers[index]?.userId,
name: emailChanged ? undefined : newApprovers[index]?.name,
department: emailChanged ? undefined : newApprovers[index]?.department,
avatar: emailChanged ? undefined : newApprovers[index]?.avatar
};
updateFormData('approvers', newApprovers);
if (!value || !value.startsWith('@') || value.length < 2) {
clearSearchForIndex(index);
return;
}
searchUsersForIndex(index, value, 10);
};
const handleUserSelect = async (index: number, selectedUser: any) => {
try {
// Check for duplicates in other approver slots (excluding current index)
const isDuplicateApprover = formData.approvers?.some(
(approver: any, idx: number) =>
idx !== index &&
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
);
if (isDuplicateApprover) {
onValidationError({
type: 'error',
email: selectedUser.email,
message: 'This user is already added as an approver in another level.'
});
return;
}
// Check for duplicates in spectators
const isDuplicateSpectator = formData.spectators?.some(
(spectator: any) => spectator.userId === selectedUser.userId || spectator.email?.toLowerCase() === selectedUser.email?.toLowerCase()
);
if (isDuplicateSpectator) {
onValidationError({
type: 'error',
email: selectedUser.email,
message: 'This user is already added as a spectator. A user cannot be both an approver and a spectator.'
});
return;
}
const dbUser = await ensureUserExists({
userId: selectedUser.userId,
email: selectedUser.email,
displayName: selectedUser.displayName,
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
department: selectedUser.department,
phone: selectedUser.phone,
mobilePhone: selectedUser.mobilePhone,
designation: selectedUser.designation,
jobTitle: selectedUser.jobTitle,
manager: selectedUser.manager,
employeeId: selectedUser.employeeId,
employeeNumber: selectedUser.employeeNumber,
secondEmail: selectedUser.secondEmail,
location: selectedUser.location
});
const updated = [...formData.approvers];
updated[index] = {
...updated[index],
email: selectedUser.email,
name: selectedUser.displayName || [selectedUser.firstName, selectedUser.lastName].filter(Boolean).join(' '),
userId: dbUser.userId,
level: index + 1,
};
updateFormData('approvers', updated);
clearSearchForIndex(index);
} catch (err) {
console.error('Failed to ensure user exists:', err);
onValidationError({
type: 'error',
email: selectedUser.email,
message: 'Failed to validate user. Please try again.'
});
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="approval-workflow-step"
>
<div className="text-center mb-8" data-testid="approval-workflow-header">
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-red-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="approval-workflow-title">
Approval Workflow
</h2>
<p className="text-gray-600" data-testid="approval-workflow-description">
Define the approval hierarchy and assign approvers by email ID.
</p>
</div>
<div className="max-w-4xl mx-auto space-y-8" data-testid="approval-workflow-content">
{/* Number of Approvers */}
<Card data-testid="approval-workflow-config-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="approval-workflow-config-title">
<Settings className="w-5 h-5" />
Approval Configuration
</CardTitle>
<CardDescription>
Configure how many approvers you need and define the approval sequence.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div data-testid="approval-workflow-count-field">
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentCount = formData.approverCount || 1;
const newCount = Math.max(1, currentCount - 1);
updateFormData('approverCount', newCount);
if (formData.approvers.length > newCount) {
updateFormData('approvers', formData.approvers.slice(0, newCount));
}
}}
disabled={(formData.approverCount || 1) <= 1}
data-testid="approval-workflow-decrease-count"
>
<Minus className="w-4 h-4" />
</Button>
<span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display">
{formData.approverCount || 1}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentCount = formData.approverCount || 1;
const newCount = currentCount + 1;
// Validate against system policy
if (newCount > systemPolicy.maxApprovalLevels) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `Cannot add more than ${systemPolicy.maxApprovalLevels} approval levels. Please remove an approver level or contact your administrator.`,
currentValue: newCount,
maxValue: systemPolicy.maxApprovalLevels
}]);
return;
}
updateFormData('approverCount', newCount);
}}
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
data-testid="approval-workflow-increase-count"
>
<Plus className="w-4 h-4" />
</Button>
</div>
<p className="text-sm text-gray-600 mt-2">
Maximum {systemPolicy.maxApprovalLevels} approver{systemPolicy.maxApprovalLevels !== 1 ? 's' : ''} allowed. Each approver will review sequentially.
</p>
</div>
</CardContent>
</Card>
{/* Approval Hierarchy */}
<Card data-testid="approval-workflow-hierarchy-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="approval-workflow-hierarchy-title">
<Shield className="w-5 h-5" />
Approval Hierarchy *
</CardTitle>
<CardDescription>
Define the approval sequence. Each approver will review the request in order from Level 1 to Level {formData.approverCount || 1}.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Initiator Card */}
<div className="p-4 rounded-lg border-2 border-blue-200 bg-blue-50" data-testid="approval-workflow-initiator-card">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-blue-900">Request Initiator</span>
<Badge variant="secondary" className="text-xs">YOU</Badge>
</div>
<p className="text-sm text-blue-700">Creates and submits the request</p>
</div>
</div>
</div>
{/* Dynamic Approver Cards */}
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
const level = index + 1;
const isLast = level === (formData.approverCount || 1);
// Ensure approver exists (should be initialized by useEffect, but provide fallback)
const approver = formData.approvers[index] || {
email: '',
name: '',
level: level,
tat: '' as any
};
return (
<div key={level} className="space-y-3" data-testid={`approval-workflow-approver-level-${level}`}>
<div className="flex justify-center">
<div className="w-px h-6 bg-gray-300"></div>
</div>
<div className={`p-4 rounded-lg border-2 transition-all ${
approver.email
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
approver.email
? 'bg-green-600'
: 'bg-gray-400'
}`}>
<span className="text-white font-semibold">{level}</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
<span className="font-semibold text-gray-900">
Approver Level {level}
</span>
{isLast && (
<Badge variant="destructive" className="text-xs">FINAL APPROVER</Badge>
)}
</div>
<div className="space-y-4">
<div data-testid={`approval-workflow-approver-${level}-email-field`}>
<div className="flex items-center justify-between mb-1">
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
Email Address *
</Label>
{approver.email && approver.userId && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
<CheckCircle className="w-3 h-3 mr-1" />
Verified
</Badge>
)}
</div>
<div className="relative">
<Input
id={`approver-${level}`}
type="email"
placeholder="approver@royalenfield.com"
value={approver.email || ''}
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"
data-testid={`approval-workflow-approver-${level}-email-input`}
/>
{/* Search suggestions dropdown */}
{(userSearchLoading[index] || (userSearchResults[index]?.length || 0) > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
{userSearchLoading[index] ? (
<div className="p-2 text-xs text-gray-500">Searching...</div>
) : (
<ul className="max-h-56 overflow-auto divide-y">
{userSearchResults[index]?.map((u) => (
<li
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => handleUserSelect(index, u)}
data-testid={`approval-workflow-approver-${level}-search-result-${u.userId}`}
>
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
<div className="text-xs text-gray-600">{u.email}</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
<div data-testid={`approval-workflow-approver-${level}-tat-field`}>
<Label htmlFor={`tat-${level}`} className="text-sm font-medium">
TAT (Turn Around Time) *
</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id={`tat-${level}`}
type="number"
placeholder={approver.tatType === 'days' ? '7' : '24'}
min="1"
max={approver.tatType === 'days' ? '30' : '720'}
value={approver.tat || ''}
onChange={(e) => {
const newApprovers = [...formData.approvers];
newApprovers[index] = {
...newApprovers[index],
tat: parseInt(e.target.value) || '',
level: level,
tatType: approver.tatType || 'hours'
};
updateFormData('approvers', newApprovers);
}}
className="h-10 border-2 border-gray-300 focus:border-blue-500 flex-1"
data-testid={`approval-workflow-approver-${level}-tat-input`}
/>
<Select
value={approver.tatType || 'hours'}
onValueChange={(value) => {
const newApprovers = [...formData.approvers];
newApprovers[index] = {
...newApprovers[index],
tatType: value as 'hours' | 'days',
level: level,
tat: ''
};
updateFormData('approvers', newApprovers);
}}
data-testid={`approval-workflow-approver-${level}-tat-type-select`}
>
<SelectTrigger className="w-20 h-10 border-2 border-gray-300">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
{/* TAT Summary Section */}
<div className="mt-6 space-y-4">
{/* Approval Flow Summary */}
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="font-semibold text-blue-900 mb-1">Approval Flow Summary</h4>
<p className="text-sm text-blue-700">
Your request will follow this sequence: <strong>You (Initiator)</strong> {Array.from({ length: formData.approverCount || 1 }, (_, i) => `Level ${i + 1} Approver`).join(' → ')}. The final approver can close the request.
</p>
</div>
</div>
</div>
{/* TAT Summary */}
<div className="p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-lg border border-emerald-200">
<div className="flex items-start gap-3">
<Clock className="w-5 h-5 text-emerald-600 mt-0.5" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-emerald-900">TAT Summary</h4>
<div className="text-right">
{(() => {
// Calculate total calendar days (for display)
// Days: count as calendar days
// Hours: convert to calendar days (hours / 24)
const totalCalendarDays = formData.approvers?.reduce((sum: number, a: any) => {
const tat = Number(a.tat || 0);
const tatType = a.tatType || 'hours';
if (tatType === 'days') {
return sum + tat; // Calendar days
} else {
return sum + (tat / 24); // Convert hours to calendar days
}
}, 0) || 0;
const displayDays = Math.ceil(totalCalendarDays);
return (
<>
<div className="text-lg font-bold text-emerald-800">{displayDays} {displayDays === 1 ? 'Day' : 'Days'}</div>
<div className="text-xs text-emerald-600">Total Duration</div>
</>
);
})()}
</div>
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{formData.approvers?.map((approver: any, idx: number) => {
const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours';
// Convert days to hours: 1 day = 24 hours
const hours = tatType === 'days' ? tat * 24 : tat;
if (!tat) return null;
return (
<div key={idx} className="bg-white/60 p-2 rounded border border-emerald-100">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-emerald-900">Level {idx + 1}</span>
<span className="text-sm text-emerald-700">{hours} {hours === 1 ? 'hour' : 'hours'}</span>
</div>
</div>
);
})}
</div>
{(() => {
// Convert all TAT to hours first
// Days: 1 day = 24 hours
// Hours: already in hours
const totalHours = formData.approvers?.reduce((sum: number, a: any) => {
const tat = Number(a.tat || 0);
const tatType = a.tatType || 'hours';
if (tatType === 'days') {
// 1 day = 24 hours
return sum + (tat * 24);
} else {
return sum + tat;
}
}, 0) || 0;
// Convert total hours to working days (8 hours per working day)
const workingDays = Math.ceil(totalHours / 8);
if (totalHours === 0) return null;
return (
<div className="bg-white/80 p-3 rounded border border-emerald-200">
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-lg font-bold text-emerald-800">{totalHours}{totalHours === 1 ? 'h' : 'h'}</div>
<div className="text-xs text-emerald-600">Total Hours</div>
</div>
<div>
<div className="text-lg font-bold text-emerald-800">{workingDays}</div>
<div className="text-xs text-emerald-600">Working Days*</div>
</div>
</div>
<p className="text-xs text-emerald-600 mt-2 text-center">*Based on 8-hour working days</p>
</div>
);
})()}
</div>
</div>
</div>
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -1,228 +0,0 @@
import { motion } from 'framer-motion';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Badge } from '@/components/ui/badge';
import { FileText, Zap, Clock } from 'lucide-react';
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
interface BasicInformationStepProps {
formData: FormData;
selectedTemplate: RequestTemplate | null;
updateFormData: (field: keyof FormData, value: any) => void;
}
/**
* Component: BasicInformationStep
*
* Purpose: Step 2 - Basic information form (title, description, priority)
*
* Features:
* - Request title and description inputs
* - Priority selection (Express/Standard)
* - Template-specific additional fields
* - Test IDs for testing
*/
export function BasicInformationStep({
formData,
selectedTemplate,
updateFormData
}: BasicInformationStepProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="basic-information-step"
>
<div className="text-center mb-8" data-testid="basic-information-header">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<FileText className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="basic-information-title">
Basic Information
</h2>
<p className="text-gray-600" data-testid="basic-information-description">
Provide the essential details for your {selectedTemplate?.name || 'request'}.
</p>
</div>
<div className="max-w-2xl mx-auto space-y-6" data-testid="basic-information-form">
<div data-testid="basic-information-title-field">
<Label htmlFor="title" className="text-base font-semibold">Request Title *</Label>
<p className="text-sm text-gray-600 mb-3">
Be specific and descriptive. This will be visible to all participants.
</p>
<Input
id="title"
placeholder="e.g., Approval on new office location"
value={formData.title}
onChange={(e) => updateFormData('title', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-title-input"
/>
</div>
<div data-testid="basic-information-description-field">
<Label htmlFor="description" className="text-base font-semibold">Detailed Description *</Label>
<p className="text-sm text-gray-600 mb-3">
Explain what you need approval for, why it's needed, and any relevant background information.
<span className="block mt-1 text-xs text-blue-600">
💡 Tip: You can paste formatted content (lists, tables) and the formatting will be preserved.
</span>
</p>
<RichTextEditor
value={formData.description || ''}
onChange={(html) => updateFormData('description', html)}
placeholder="Provide comprehensive details about your request including scope, objectives, expected outcomes, and any supporting context that will help approvers make an informed decision."
className="min-h-[120px] text-base border-2 border-gray-300 focus-within:border-blue-500 bg-white shadow-sm"
minHeight="120px"
data-testid="basic-information-description-textarea"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6" data-testid="basic-information-priority-section">
<div data-testid="basic-information-priority-field">
<Label className="text-base font-semibold">Priority Level *</Label>
<p className="text-sm text-gray-600 mb-2">
select priority for your request
</p>
<RadioGroup
value={formData.priority || ''}
onValueChange={(value) => updateFormData('priority', value)}
data-testid="basic-information-priority-radio-group"
>
<div
className={`flex items-center space-x-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
formData.priority === 'express'
? 'border-red-500 bg-red-100'
: 'border-red-200 bg-red-50 hover:bg-red-100'
}`}
onClick={() => updateFormData('priority', 'express')}
data-testid="basic-information-priority-express-option"
>
<RadioGroupItem value="express" id="express" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Zap className="w-4 h-4 text-red-600" />
<Label htmlFor="express" className="font-medium text-red-900 cursor-pointer">Express</Label>
<Badge variant="destructive" className="text-xs">URGENT</Badge>
</div>
<p className="text-xs text-red-700">
Includes calendar days in TAT - faster processing timeline
</p>
</div>
</div>
<div
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-all ${
formData.priority === 'standard'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
onClick={() => updateFormData('priority', 'standard')}
data-testid="basic-information-priority-standard-option"
>
<RadioGroupItem value="standard" id="standard" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-blue-600" />
<Label htmlFor="standard" className="font-medium text-blue-900 cursor-pointer">Standard</Label>
<Badge variant="secondary" className="text-xs">DEFAULT</Badge>
</div>
<p className="text-xs text-gray-600">
Includes working days in TAT - regular processing timeline
</p>
</div>
</div>
</RadioGroup>
</div>
</div>
{/* Template-specific fields */}
{(selectedTemplate?.fields.amount || selectedTemplate?.fields.vendor || selectedTemplate?.fields.timeline || selectedTemplate?.fields.impact) && (
<div className="border-t pt-6" data-testid="basic-information-additional-details">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Additional Details</h3>
<div className="space-y-6">
{selectedTemplate?.fields.amount && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4" data-testid="basic-information-amount-field">
<div className="md:col-span-2">
<Label htmlFor="amount" className="text-base font-semibold">Budget Amount</Label>
<Input
id="amount"
placeholder="Enter amount"
value={formData.amount}
onChange={(e) => updateFormData('amount', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-amount-input"
/>
</div>
<div>
<Label className="text-base font-semibold">Currency</Label>
<Select
value={formData.currency}
onValueChange={(value) => updateFormData('currency', value)}
data-testid="basic-information-currency-select"
>
<SelectTrigger className="h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="GBP">GBP (£)</SelectItem>
<SelectItem value="INR">INR ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{selectedTemplate?.fields.vendor && (
<div data-testid="basic-information-vendor-field">
<Label htmlFor="vendor" className="text-base font-semibold">Vendor/Supplier</Label>
<Input
id="vendor"
placeholder="Enter vendor or supplier name"
value={formData.vendor}
onChange={(e) => updateFormData('vendor', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-vendor-input"
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div data-testid="basic-information-cost-center-field">
<Label htmlFor="costCenter" className="text-base font-semibold">Cost Center</Label>
<Input
id="costCenter"
placeholder="e.g., Marketing, IT, Operations"
value={formData.costCenter}
onChange={(e) => updateFormData('costCenter', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-cost-center-input"
/>
</div>
<div data-testid="basic-information-project-field">
<Label htmlFor="project" className="text-base font-semibold">Related Project</Label>
<Input
id="project"
placeholder="Associated project name or code"
value={formData.project}
onChange={(e) => updateFormData('project', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-project-input"
/>
</div>
</div>
</div>
</div>
)}
</div>
</motion.div>
);
}

View File

@ -1,307 +0,0 @@
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Upload, FileText, Eye, X, Plus } from 'lucide-react';
interface DocumentPolicy {
maxFileSizeMB: number;
allowedFileTypes: string[];
}
interface DocumentsStepProps {
documentPolicy: DocumentPolicy;
isEditing: boolean;
documents: File[];
existingDocuments: any[];
documentsToDelete: string[];
onDocumentsChange: (documents: File[]) => void;
onExistingDocumentsChange: (documents: any[]) => void;
onDocumentsToDeleteChange: (ids: string[]) => void;
onPreviewDocument: (doc: any, isExisting: boolean) => void;
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
fileInputRef: React.RefObject<HTMLInputElement>;
}
/**
* Component: DocumentsStep
*
* Purpose: Step 5 - Document upload and management
*
* Features:
* - File upload with validation
* - Preview existing documents
* - Delete documents
* - Test IDs for testing
*/
export function DocumentsStep({
documentPolicy,
isEditing,
documents,
existingDocuments,
documentsToDelete,
onDocumentsChange,
onExistingDocumentsChange: _onExistingDocumentsChange,
onDocumentsToDeleteChange,
onPreviewDocument,
onDocumentErrors,
fileInputRef
}: DocumentsStepProps) {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
// Validate files
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = [];
files.forEach(file => {
// Check file size
if (file.size > maxSizeBytes) {
validationErrors.push({
fileName: file.name,
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
});
return;
}
// Check file extension
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
validationErrors.push({
fileName: file.name,
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
});
return;
}
validFiles.push(file);
});
// Update parent with valid files
if (validFiles.length > 0) {
onDocumentsChange([...documents, ...validFiles]);
}
// Show errors if any
if (validationErrors.length > 0 && onDocumentErrors) {
onDocumentErrors(validationErrors);
}
// Reset file input
if (event.target) {
event.target.value = '';
}
};
const handleRemove = (index: number) => {
const newDocs = documents.filter((_, i) => i !== index);
onDocumentsChange(newDocs);
};
const handleDeleteExisting = (docId: string) => {
onDocumentsToDeleteChange([...documentsToDelete, docId]);
};
const canPreview = (doc: any, isExisting: boolean = false): boolean => {
if (isExisting) {
const type = (doc.fileType || doc.file_type || '').toLowerCase();
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
} else {
const type = (doc.type || '').toLowerCase();
const name = (doc.name || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="documents-step"
>
<div className="text-center mb-8" data-testid="documents-header">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Upload className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="documents-title">
Documents & Attachments
</h2>
<p className="text-gray-600" data-testid="documents-description">
Upload supporting documents, files, and any additional materials for your request.
</p>
</div>
<div className="max-w-2xl mx-auto space-y-6" data-testid="documents-content">
<Card data-testid="documents-upload-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="documents-upload-title">
<FileText className="w-5 h-5" />
File Upload
</CardTitle>
<CardDescription>
Attach supporting documents. Max {documentPolicy.maxFileSizeMB}MB per file. Allowed types: {documentPolicy.allowedFileTypes.join(', ')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors" data-testid="documents-upload-area">
<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>
<p className="text-gray-600 mb-4">
Drag and drop files here, or click to browse
</p>
<input
type="file"
multiple
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
onChange={handleFileChange}
className="hidden"
id="file-upload"
ref={fileInputRef}
data-testid="documents-file-input"
/>
<Button
variant="outline"
size="lg"
type="button"
onClick={() => fileInputRef.current?.click()}
data-testid="documents-browse-button"
>
<Plus className="w-4 h-4 mr-2" />
Browse Files
</Button>
<p className="text-xs text-gray-500 mt-2">
Supported formats: {documentPolicy.allowedFileTypes.map(ext => ext.toUpperCase()).join(', ')} (Max {documentPolicy.maxFileSizeMB}MB per file)
</p>
</div>
</CardContent>
</Card>
{/* Existing Documents */}
{isEditing && existingDocuments.length > 0 && (
<Card data-testid="documents-existing-card">
<CardHeader>
<CardTitle className="flex items-center justify-between" data-testid="documents-existing-title">
<span>Existing Documents</span>
<Badge variant="secondary" data-testid="documents-existing-count">
{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length} file{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length !== 1 ? 's' : ''}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3" data-testid="documents-existing-list">
{existingDocuments.map((doc: any) => {
const docId = doc.documentId || doc.document_id || '';
const isDeleted = documentsToDelete.includes(docId);
if (isDeleted) return null;
return (
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{doc.originalFileName || doc.fileName || 'Document'}</p>
<div className="flex items-center gap-3 text-sm text-gray-600">
<span>{doc.fileSize ? (Number(doc.fileSize) / (1024 * 1024)).toFixed(2) + ' MB' : 'Unknown size'}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{canPreview(doc, true) && (
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewDocument(doc, true)}
data-testid={`documents-existing-${docId}-preview`}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteExisting(docId)}
data-testid={`documents-existing-${docId}-delete`}
>
<X className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* New Documents */}
{documents.length > 0 && (
<Card data-testid="documents-new-card">
<CardHeader>
<CardTitle className="flex items-center justify-between" data-testid="documents-new-title">
<span>New Files to Upload</span>
<Badge variant="secondary" data-testid="documents-new-count">
{documents.length} file{documents.length !== 1 ? 's' : ''}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3" data-testid="documents-new-list">
{documents.map((file, index) => (
<div key={index} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border" data-testid={`documents-new-${index}`}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{file.name}</p>
<div className="flex items-center gap-3 text-sm text-gray-600">
<span>{(file.size / (1024 * 1024)).toFixed(2)} MB</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{canPreview(file, false) && (
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewDocument(file, false)}
data-testid={`documents-new-${index}-preview`}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(index)}
data-testid={`documents-new-${index}-remove`}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</motion.div>
);
}

View File

@ -1,282 +0,0 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Eye, Info, X } from 'lucide-react';
import { FormData } from '@/hooks/useCreateRequestForm';
import { useUserSearch } from '@/hooks/useUserSearch';
interface ParticipantsStepProps {
formData: FormData;
updateFormData: (field: keyof FormData, value: any) => void;
onValidationError: (error: { type: string; email: string; message: string }) => void;
initiatorEmail: string;
}
/**
* Component: ParticipantsStep
*
* Purpose: Step 4 - Participants & access settings
*
* Features:
* - Add spectators with @ search
* - Manage spectator list
* - Test IDs for testing
*/
export function ParticipantsStep({
formData,
updateFormData,
onValidationError,
initiatorEmail
}: ParticipantsStepProps) {
const [emailInput, setEmailInput] = useState('');
const { searchResults, searchLoading, searchUsersDebounced, clearSearch, ensureUser } = useUserSearch();
const handleEmailInputChange = (value: string) => {
setEmailInput(value);
if (!value || !value.startsWith('@') || value.length < 2) {
clearSearch();
return;
}
searchUsersDebounced(value, 10);
};
const handleAddSpectator = async (user?: any) => {
if (user) {
// Add from search results
if (user.email.toLowerCase() === initiatorEmail.toLowerCase()) {
onValidationError({
type: 'self-assign',
email: user.email,
message: 'You cannot add yourself as a spectator.'
});
return;
}
// Check for duplicates in spectators
const isDuplicateSpectator = formData.spectators.some(
(s: any) => s.userId === user.userId || s.email?.toLowerCase() === user.email?.toLowerCase()
);
// Check for duplicates in approvers
const isDuplicateApprover = formData.approvers?.some(
(approver: any) => approver.userId === user.userId || approver.email?.toLowerCase() === user.email?.toLowerCase()
);
if (isDuplicateSpectator) {
onValidationError({
type: 'error',
email: user.email,
message: 'This user is already added as a spectator.'
});
return;
}
if (isDuplicateApprover) {
onValidationError({
type: 'error',
email: user.email,
message: 'This user is already added as an approver. A user cannot be both an approver and a spectator.'
});
return;
}
try {
const dbUser = await ensureUser(user);
const spectator = {
id: dbUser.userId,
userId: dbUser.userId,
name: dbUser.displayName || user.email.split('@')[0],
email: dbUser.email,
avatar: (dbUser.displayName || dbUser.email).substring(0, 2).toUpperCase(),
role: 'Spectator',
department: dbUser.department || '',
level: 1,
canClose: false
};
const updatedSpectators = [...formData.spectators, spectator];
updateFormData('spectators', updatedSpectators);
setEmailInput('');
clearSearch();
} catch (err) {
onValidationError({
type: 'error',
email: user.email,
message: 'Failed to validate user. Please try again.'
});
}
} else if (emailInput && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput)) {
// Add by email directly (will be validated)
if (emailInput.toLowerCase() === initiatorEmail.toLowerCase()) {
onValidationError({
type: 'self-assign',
email: emailInput,
message: 'You cannot add yourself as a spectator.'
});
return;
}
// Check for duplicates in spectators by email
const isDuplicateSpectator = formData.spectators.some(
(s: any) => s.email?.toLowerCase() === emailInput.toLowerCase()
);
// Check for duplicates in approvers by email
const isDuplicateApprover = formData.approvers?.some(
(approver: any) => approver.email?.toLowerCase() === emailInput.toLowerCase()
);
if (isDuplicateSpectator) {
onValidationError({
type: 'error',
email: emailInput,
message: 'This user is already added as a spectator.'
});
return;
}
if (isDuplicateApprover) {
onValidationError({
type: 'error',
email: emailInput,
message: 'This user is already added as an approver. A user cannot be both an approver and a spectator.'
});
return;
}
// This would trigger validation in parent component
}
};
const removeSpectator = (spectatorId: string) => {
const updatedSpectators = formData.spectators.filter((s: any) => s.id !== spectatorId);
updateFormData('spectators', updatedSpectators);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="participants-step"
>
<div className="text-center mb-8" data-testid="participants-header">
<div className="w-16 h-16 bg-gradient-to-br from-teal-500 to-green-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Eye className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="participants-title">
Participants & Access
</h2>
<p className="text-gray-600" data-testid="participants-description">
Configure additional participants and visibility settings for your request.
</p>
</div>
<div className="max-w-3xl mx-auto space-y-8" data-testid="participants-content">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Spectators */}
<Card data-testid="participants-spectators-card">
<CardHeader>
<CardTitle className="flex items-center justify-between text-base" data-testid="participants-spectators-title">
<div className="flex items-center gap-2">
<Eye className="w-4 h-4" />
Spectators
</div>
<Badge variant="outline" className="text-xs" data-testid="participants-spectators-count">
{formData.spectators.length}
</Badge>
</CardTitle>
<CardDescription>
Users who can view and comment but cannot approve
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2" data-testid="participants-spectators-add-section">
<div className="flex items-center gap-2">
<div className="relative w-full">
<Input
placeholder="Use @ sign to add a user"
value={emailInput}
onChange={(e) => handleEmailInputChange(e.target.value)}
onKeyPress={async (e) => {
if (e.key === 'Enter' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput)) {
e.preventDefault();
await handleAddSpectator();
}
}}
className="text-sm w-full"
data-testid="participants-spectators-email-input"
/>
{(searchLoading || searchResults.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
{searchLoading ? (
<div className="p-2 text-xs text-gray-500">Searching...</div>
) : (
<ul className="max-h-56 overflow-auto divide-y">
{searchResults.map(u => (
<li
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => handleAddSpectator(u)}
data-testid={`participants-spectators-search-result-${u.userId}`}
>
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
<div className="text-xs text-gray-600">{u.email}</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
<Button
size="sm"
onClick={() => handleAddSpectator()}
disabled={!emailInput || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput)}
data-testid="participants-spectators-add-button"
>
Add
</Button>
</div>
<p className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded p-2 flex items-center gap-1">
<Info className="w-3 h-3 flex-shrink-0" />
<span>
Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search users, or type email directly (will be validated against organization directory)
</span>
</p>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto" data-testid="participants-spectators-list">
{formData.spectators.map((spectator) => (
<div key={spectator.id} className="flex items-center justify-between p-2 bg-teal-50 rounded-lg" data-testid={`participants-spectator-${spectator.id}`}>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarFallback className="bg-teal-600 text-white text-xs">
{spectator.avatar}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{spectator.name}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeSpectator(spectator.id)}
data-testid={`participants-spectator-${spectator.id}-remove`}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</motion.div>
);
}

View File

@ -1,283 +0,0 @@
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { FormattedDescription } from '@/components/common/FormattedDescription';
import { CheckCircle, Rocket, FileText, Users, Eye, Upload, Flame, Target, TrendingUp, DollarSign } from 'lucide-react';
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
interface ReviewSubmitStepProps {
formData: FormData;
selectedTemplate: RequestTemplate | null;
}
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'high': return <Flame className="w-4 h-4 text-red-600" />;
case 'medium': return <Target className="w-4 h-4 text-orange-600" />;
case 'low': return <TrendingUp className="w-4 h-4 text-green-600" />;
default: return <Target className="w-4 h-4 text-gray-600" />;
}
};
/**
* Component: ReviewSubmitStep
*
* Purpose: Step 6 - Review and submit request
*
* Features:
* - Displays all request information for review
* - Shows approval workflow summary
* - Lists participants and documents
* - Test IDs for testing
*/
export function ReviewSubmitStep({
formData,
selectedTemplate
}: ReviewSubmitStepProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="review-submit-step"
>
<div className="text-center mb-8" data-testid="review-submit-header">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-teal-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" data-testid="review-submit-title">
Review & Submit
</h2>
<p className="text-gray-600" data-testid="review-submit-description">
Please review all details before submitting your request for approval.
</p>
</div>
<div className="max-w-5xl mx-auto space-y-8" data-testid="review-submit-content">
{/* Request Overview */}
<Card className="border-2 border-green-200 bg-green-50/50" data-testid="review-submit-overview-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-900" data-testid="review-submit-overview-title">
<Rocket className="w-5 h-5" />
Request Overview
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" data-testid="review-submit-overview-grid">
<div data-testid="review-submit-overview-type">
<Label className="text-green-900 font-semibold">Request Type</Label>
<p className="text-green-800 mt-1">{selectedTemplate?.name}</p>
<Badge variant="outline" className="mt-2 text-xs border-green-300 text-green-700">
{selectedTemplate?.category}
</Badge>
</div>
<div data-testid="review-submit-overview-priority">
<Label className="text-green-900 font-semibold">Priority</Label>
<div className="flex items-center gap-2 mt-1">
{getPriorityIcon(formData.priority)}
<span className="text-green-800 capitalize">{formData.priority}</span>
</div>
</div>
<div data-testid="review-submit-overview-workflow">
<Label className="text-green-900 font-semibold">Workflow Type</Label>
<p className="text-green-800 mt-1 capitalize">{formData.workflowType}</p>
<p className="text-sm text-green-700">{formData.approverCount || 1} Level{(formData.approverCount || 1) > 1 ? 's' : ''}</p>
</div>
</div>
<div data-testid="review-submit-overview-title">
<Label className="text-green-900 font-semibold">Request Title</Label>
<p className="text-green-800 font-medium mt-1 text-lg">{formData.title}</p>
</div>
</CardContent>
</Card>
{/* Basic Information */}
<Card data-testid="review-submit-basic-info-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="review-submit-basic-info-title">
<FileText className="w-5 h-5" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div data-testid="review-submit-basic-info-description">
<Label className="font-semibold">Description</Label>
<div className="mt-1 p-3 bg-gray-50 rounded-lg border">
<FormattedDescription content={formData.description || ''} />
</div>
</div>
{formData.amount && (
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200" data-testid="review-submit-basic-info-financial">
<div className="flex items-center gap-2 mb-3">
<DollarSign className="w-4 h-4 text-blue-600" />
<Label className="font-semibold text-blue-900">Financial Details</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span className="text-sm text-blue-700">Amount</span>
<p className="font-semibold text-blue-900">{formData.amount} {formData.currency}</p>
</div>
{formData.costCenter && (
<div>
<span className="text-sm text-blue-700">Cost Center</span>
<p className="font-medium text-blue-900">{formData.costCenter}</p>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* Approval Workflow */}
<Card className="border-2 border-orange-200 bg-orange-50/50" data-testid="review-submit-approval-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-orange-900" data-testid="review-submit-approval-title">
<Users className="w-5 h-5" />
Approval Workflow
</CardTitle>
<CardDescription className="text-orange-700">
Sequential approval hierarchy with TAT (Turn Around Time) for each level
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4" data-testid="review-submit-approval-levels">
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
const level = index + 1;
const isLast = level === (formData.approverCount || 1);
const approver = formData.approvers[index];
return (
<div key={level} className="p-4 bg-white rounded-lg border border-orange-200" data-testid={`review-submit-approval-level-${level}`}>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
approver?.email ? 'bg-green-600' : 'bg-gray-400'
}`}>
<span className="text-white font-semibold">{level}</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-semibold text-gray-900">
Approver Level {level}
</span>
{isLast && (
<Badge variant="destructive" className="text-xs">FINAL APPROVER</Badge>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span className="text-sm text-gray-600">Email Address</span>
<p className="text-sm font-medium text-gray-900">
{approver?.email || 'Not assigned'}
</p>
</div>
<div>
<span className="text-sm text-gray-600">TAT (Turn Around Time)</span>
<p className="text-sm font-medium text-gray-900">
{approver?.tat ?
`${approver.tat} ${approver.tatType === 'days' ? 'day' : 'hour'}${approver.tat !== 1 ? 's' : ''}`
: 'Not set'
}
</p>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Participants & Access */}
<Card data-testid="review-submit-participants-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="review-submit-participants-title">
<Eye className="w-5 h-5" />
Participants & Access Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{formData.spectators.length > 0 && (
<div data-testid="review-submit-participants-spectators">
<Label className="font-semibold text-sm">Spectators ({formData.spectators.length})</Label>
<div className="flex flex-wrap gap-2 mt-2">
{formData.spectators.map((spectator) => (
<Badge key={spectator.id} variant="outline" className="text-xs" data-testid={`review-submit-spectator-${spectator.id}`}>
{spectator.name} ({spectator.email})
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Documents */}
{formData.documents.length > 0 && (
<Card data-testid="review-submit-documents-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="review-submit-documents-title">
<Upload className="w-5 h-5" />
Documents & Attachments
</CardTitle>
<CardDescription>
{formData.documents.length} document{formData.documents.length !== 1 ? 's' : ''} attached to this request
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3" data-testid="review-submit-documents-list">
{formData.documents.map((doc, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border" data-testid={`review-submit-document-${index}`}>
<FileText className="w-5 h-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{doc.name}</p>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
<span>{(doc.size / (1024 * 1024)).toFixed(2)} MB</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Final Confirmation */}
<Card className="border-2 border-blue-200 bg-blue-50/50" data-testid="review-submit-confirmation-card">
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<CheckCircle className="w-6 h-6 text-blue-600 mt-1 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-blue-900 mb-2" data-testid="review-submit-confirmation-title">
Ready to Submit Request
</h3>
<p className="text-sm text-blue-700 mb-4" data-testid="review-submit-confirmation-message">
Once submitted, your request will enter the approval workflow and notifications will be sent to all relevant participants.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm" data-testid="review-submit-confirmation-summary">
<div>
<span className="text-blue-700">Request Type:</span>
<p className="font-medium text-blue-900">{selectedTemplate?.name}</p>
</div>
<div>
<span className="text-blue-700">Approval Levels:</span>
<p className="font-medium text-blue-900">{formData.approverCount || 1}</p>
</div>
<div>
<span className="text-blue-700">Documents:</span>
<p className="font-medium text-blue-900">{formData.documents.length} attached</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</motion.div>
);
}

View File

@ -1,294 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Check, Clock, Users, Flame, Target, TrendingUp, FolderOpen, ArrowLeft, Info } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
interface TemplateSelectionStepProps {
templates: RequestTemplate[];
selectedTemplate: RequestTemplate | null;
onSelectTemplate: (template: RequestTemplate) => void;
adminTemplates?: RequestTemplate[];
}
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'high': return <Flame className="w-4 h-4 text-red-600" />;
case 'medium': return <Target className="w-4 h-4 text-orange-600" />;
case 'low': return <TrendingUp className="w-4 h-4 text-green-600" />;
default: return <Target className="w-4 h-4 text-gray-600" />;
}
};
export function TemplateSelectionStep({
templates,
selectedTemplate,
onSelectTemplate,
adminTemplates = []
}: TemplateSelectionStepProps) {
const [viewMode, setViewMode] = useState<'main' | 'admin'>('main');
const navigate = useNavigate();
const handleTemplateClick = (template: RequestTemplate) => {
if (template.id === 'admin-templates-category') {
setViewMode('admin');
} else {
if (viewMode === 'admin') {
// If selecting an actual admin template, redirect to dedicated flow
navigate(`/create-admin-request/${template.id}`);
} else {
// Default behavior for standard templates
onSelectTemplate(template);
}
}
};
const displayTemplates = viewMode === 'main'
? [
...templates,
{
id: 'admin-templates-category',
name: 'Admin Templates',
description: 'Browse standardized request workflows created by your organization administrators',
category: 'Organization',
icon: FolderOpen,
estimatedTime: 'Variable',
commonApprovers: [],
suggestedSLA: 0,
priority: 'medium',
fields: {}
} as any
]
: adminTemplates;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="min-h-full flex flex-col items-center justify-center py-8"
data-testid="template-selection-step"
>
{/* Header Section */}
<div className="text-center mb-12 max-w-3xl" data-testid="template-selection-header">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4" data-testid="template-selection-title">
{viewMode === 'main' ? 'Choose Your Request Type' : 'Organization Templates'}
</h1>
<p className="text-lg text-gray-600" data-testid="template-selection-description">
{viewMode === 'main'
? 'Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.'
: 'Select a pre-configured workflow template defined by your organization.'}
</p>
</div>
{viewMode === 'admin' && (
<div className="w-full max-w-6xl mb-6 flex justify-start">
<Button variant="ghost" className="gap-2" onClick={() => setViewMode('main')}>
<ArrowLeft className="w-4 h-4" />
Back to All Types
</Button>
</div>
)}
{/* Template Cards Grid */}
<div
className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"
data-testid="template-selection-grid"
>
{displayTemplates.length === 0 && viewMode === 'admin' ? (
<div className="col-span-full text-center py-12 text-gray-500 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
<FolderOpen className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>No admin templates available yet.</p>
</div>
) : (
displayTemplates.map((template) => {
const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
const isDisabled = isComingSoon;
const isCategoryCard = template.id === 'admin-templates-category';
// const isCustomCard = template.id === 'custom';
const isSelected = selectedTemplate?.id === template.id;
return (
<motion.div
key={template.id}
whileHover={!isDisabled ? { scale: 1.03 } : {}}
whileTap={!isDisabled ? { scale: 0.98 } : {}}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
data-testid={`template-card-${template.id}`}
>
<Card
className={`h-full transition-all duration-300 border-2 ${isDisabled
? 'border-gray-200 bg-gray-50/50 opacity-85 cursor-not-allowed'
: isSelected
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200 cursor-pointer'
: isCategoryCard
? 'border-blue-200 bg-blue-50/30 hover:border-blue-400 hover:shadow-lg cursor-pointer'
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg cursor-pointer'
}`}
onClick={!isDisabled ? () => handleTemplateClick(template) : undefined}
data-testid={`template-card-${template.id}-clickable`}
>
<CardHeader className="space-y-4 pb-4">
<div className="flex items-start justify-between">
<div
className={`w-14 h-14 rounded-xl flex items-center justify-center ${isSelected
? 'bg-blue-100'
: isCategoryCard
? 'bg-blue-100'
: 'bg-gray-100'
}`}
data-testid={`template-card-${template.id}-icon`}
>
<template.icon
className={`w-7 h-7 ${isSelected
? 'text-blue-600'
: isCategoryCard
? 'text-blue-600'
: 'text-gray-600'
}`}
/>
</div>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
data-testid={`template-card-${template.id}-selected-indicator`}
>
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<Check className="w-5 h-5 text-white" />
</div>
</motion.div>
)}
</div>
<div className="text-left">
<div className="flex items-start justify-between gap-2 mb-2">
<CardTitle className="text-xl" data-testid={`template-card-${template.id}-name`}>
{template.name}
</CardTitle>
{isComingSoon && (
<Badge
variant="outline"
className="text-xs bg-yellow-100 text-yellow-700 border-yellow-300 font-semibold"
data-testid={`template-card-${template.id}-coming-soon-badge`}
>
Coming Soon
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs" data-testid={`template-card-${template.id}-category`}>
{template.category}
</Badge>
{getPriorityIcon(template.priority)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<p
className="text-sm text-gray-600 leading-relaxed line-clamp-2"
data-testid={`template-card-${template.id}-description`}
>
{template.description}
</p>
{!isCategoryCard && (
<>
<Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-estimated-time`}>
<Clock className="w-3.5 h-3.5" />
<span>{template.estimatedTime}</span>
</div>
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}>
<Users className="w-3.5 h-3.5" />
<span>{template.commonApprovers?.length || 0} approvers</span>
</div>
</div>
</>
)}
{isCategoryCard && (
<div className="pt-2">
<p className="text-xs text-blue-600 font-medium flex items-center gap-1">
Click to browse templates &rarr;
</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
);
})
)}
</div>
{/* Template Details Card */}
<AnimatePresence>
{selectedTemplate && (
<motion.div
initial={{ opacity: 0, y: 20, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -20, height: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-6xl"
data-testid="template-details-card"
>
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-900" data-testid="template-details-title">
<Info className="w-5 h-5" />
{selectedTemplate.name} - Template Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-sla">
<Label className="text-blue-900 font-semibold">Suggested SLA</Label>
<p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} hours</p>
</div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority">
<Label className="text-blue-900 font-semibold">Priority Level</Label>
<div className="flex items-center gap-1 mt-1">
{getPriorityIcon(selectedTemplate.priority)}
<span className="text-blue-700 capitalize">{selectedTemplate.priority}</span>
</div>
</div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-duration">
<Label className="text-blue-900 font-semibold">Estimated Duration</Label>
<p className="text-blue-700 mt-1">{selectedTemplate.estimatedTime}</p>
</div>
</div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-approvers">
<Label className="text-blue-900 font-semibold">Approvers</Label>
<div className="flex flex-wrap gap-2 mt-2">
{selectedTemplate.commonApprovers?.length > 0 ? (
selectedTemplate.commonApprovers.map((approver, index) => (
<Badge
key={`${selectedTemplate.id}-approver-${index}-${approver}`}
variant="outline"
className="border-blue-300 text-blue-700 bg-white"
data-testid={`template-details-approver-${index}`}
>
{approver}
</Badge>
))
) : (
<span className="text-sm text-gray-500 italic">No specific approvers defined</span>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@ -1,117 +0,0 @@
import { Button } from '@/components/ui/button';
import { ArrowLeft, ArrowRight, Rocket, Loader2 } from 'lucide-react';
interface WizardFooterProps {
currentStep: number;
totalSteps: number;
isStepValid: boolean;
onPrev: () => void;
onNext: () => void;
onSubmit: () => void;
onSaveDraft: () => void;
submitting: boolean;
savingDraft: boolean;
loadingDraft: boolean;
isEditing: boolean;
}
/**
* Component: WizardFooter
*
* Purpose: Navigation footer for wizard with Previous/Next/Submit buttons
*
* Features:
* - Fixed on mobile for better keyboard handling
* - Shows loading states
* - Test IDs for testing
*/
export function WizardFooter({
currentStep,
totalSteps,
isStepValid,
onPrev,
onNext,
onSubmit,
onSaveDraft,
submitting,
savingDraft,
loadingDraft,
isEditing
}: WizardFooterProps) {
return (
<div
className="fixed sm:relative bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0 shadow-lg sm:shadow-none z-50"
data-testid="wizard-footer"
>
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-2 sm:gap-4 max-w-6xl mx-auto">
{/* Previous Button */}
<Button
variant="outline"
onClick={onPrev}
disabled={currentStep === 1}
size="sm"
className="sm:size-lg order-2 sm:order-1"
data-testid="wizard-footer-prev-button"
>
<ArrowLeft className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
<span className="text-xs sm:text-sm">Previous</span>
</Button>
{/* Action Buttons */}
<div className="flex gap-2 sm:gap-3 order-1 sm:order-2" data-testid="wizard-footer-actions">
<Button
variant="outline"
onClick={onSaveDraft}
size="sm"
className="sm:size-lg flex-1 sm:flex-none text-xs sm:text-sm"
disabled={loadingDraft || submitting || savingDraft}
data-testid="wizard-footer-save-draft-button"
>
{savingDraft ? (
<>
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
<span>{isEditing ? 'Updating...' : 'Saving...'}</span>
</>
) : (
<span>{isEditing ? 'Update Draft' : 'Save Draft'}</span>
)}
</Button>
{currentStep === totalSteps ? (
<Button
onClick={onSubmit}
disabled={!isStepValid || loadingDraft || submitting || savingDraft}
size="sm"
className="sm:size-lg bg-green-600 hover:bg-green-700 flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
data-testid="wizard-footer-submit-button"
>
{submitting ? (
<>
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
Submitting...
</>
) : (
<>
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
Submit
</>
)}
</Button>
) : (
<Button
onClick={onNext}
disabled={!isStepValid}
size="sm"
className="sm:size-lg flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
data-testid="wizard-footer-next-button"
>
<span className="hidden sm:inline">Next Step</span>
<span className="sm:hidden">Next</span>
<ArrowRight className="h-3 w-3 sm:h-4 sm:w-4 ml-1 sm:ml-2" />
</Button>
)}
</div>
</div>
</div>
);
}

View File

@ -1,119 +0,0 @@
import { Check } from 'lucide-react';
interface WizardStepperProps {
currentStep: number;
totalSteps: number;
stepNames: string[];
}
/**
* Component: WizardStepper
*
* Purpose: Displays progress indicator for multi-step wizard
*
* Features:
* - Shows current step and progress percentage
* - Mobile-optimized display
* - Test IDs for testing
*/
export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) {
const progressPercentage = Math.round((currentStep / totalSteps) * 100);
// Use a narrower container for fewer steps to avoid excessive spacing
const containerMaxWidth = stepNames.length <= 3 ? 'max-w-xl' : 'max-w-6xl';
return (
<div
className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0"
data-testid="wizard-stepper"
>
<div className={`${containerMaxWidth} mx-auto`}>
{/* Mobile: Current step indicator only */}
<div className="block sm:hidden" data-testid="wizard-stepper-mobile">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold"
data-testid="wizard-stepper-mobile-current-step"
>
{currentStep}
</div>
<div>
<p className="text-xs font-semibold text-gray-900" data-testid="wizard-stepper-mobile-step-name">
{stepNames[currentStep - 1]}
</p>
<p className="text-xs text-gray-600" data-testid="wizard-stepper-mobile-step-info">
Step {currentStep} of {totalSteps}
</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-medium text-green-600" data-testid="wizard-stepper-mobile-progress">
{progressPercentage}%
</p>
</div>
</div>
{/* Progress bar */}
<div
className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden"
data-testid="wizard-stepper-mobile-progress-bar"
>
<div
className="bg-green-600 h-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
data-testid="wizard-stepper-mobile-progress-fill"
/>
</div>
</div>
{/* Desktop: Full step indicator */}
<div className="hidden sm:block" data-testid="wizard-stepper-desktop">
<div className="flex items-center justify-center gap-4 mb-2" data-testid="wizard-stepper-desktop-steps">
{stepNames.map((_, index) => (
<div key={index} className="flex items-center flex-1 last:flex-none" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 ${index + 1 < currentStep
? 'bg-green-500 text-white'
: index + 1 === currentStep
? 'bg-green-500 text-white ring-2 ring-green-500/30 ring-offset-1'
: 'bg-gray-200 text-gray-600'
}`}
data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`}
>
{index + 1 < currentStep ? (
<Check className="w-4 h-4" />
) : (
index + 1
)}
</div>
{index < stepNames.length - 1 && (
<div
className={`flex-1 h-0.5 mx-2 ${index + 1 < currentStep ? 'bg-green-500' : 'bg-gray-200'
}`}
data-testid={`wizard-stepper-desktop-step-${index + 1}-connector`}
/>
)}
</div>
))}
</div>
<div
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2 px-1"
data-testid="wizard-stepper-desktop-labels"
>
{stepNames.map((step, index) => (
<span
key={index}
className={`${index + 1 === currentStep ? 'font-semibold text-green-600' : ''
}`}
data-testid={`wizard-stepper-desktop-label-${index + 1}`}
>
{step}
</span>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,9 +0,0 @@
export { WizardStepper } from './WizardStepper';
export { WizardFooter } from './WizardFooter';
export { TemplateSelectionStep } from './TemplateSelectionStep';
export { BasicInformationStep } from './BasicInformationStep';
export { ApprovalWorkflowStep } from './ApprovalWorkflowStep';
export { ParticipantsStep } from './ParticipantsStep';
export { DocumentsStep } from './DocumentsStep';
export { ReviewSubmitStep } from './ReviewSubmitStep';

View File

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

View File

@ -1,185 +0,0 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Loader2, Pause } from 'lucide-react';
import { toast } from 'sonner';
import { pauseWorkflow } from '@/services/workflowApi';
import dayjs from 'dayjs';
interface PauseModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
levelId: string | null;
onSuccess?: () => void | Promise<void>;
}
export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: PauseModalProps) {
const [reason, setReason] = useState('');
const [resumeDate, setResumeDate] = useState('');
const [submitting, setSubmitting] = useState(false);
// Set default resume date to 1 month from now
const getDefaultResumeDate = () => {
const maxDate = dayjs().add(1, 'month').format('YYYY-MM-DD');
return maxDate;
};
const getMaxResumeDate = () => {
return dayjs().add(1, 'month').format('YYYY-MM-DD');
};
const getMinResumeDate = () => {
return dayjs().add(1, 'day').format('YYYY-MM-DD'); // At least 1 day from now
};
// Initialize resume date when modal opens
useEffect(() => {
if (isOpen && !resumeDate) {
setResumeDate(getDefaultResumeDate());
}
}, [isOpen]);
const handleSubmit = async () => {
if (!reason.trim()) {
toast.error('Please provide a reason for pausing');
return;
}
if (!resumeDate) {
toast.error('Please select a resume date');
return;
}
const selectedDate = dayjs(resumeDate);
const maxDate = dayjs().add(1, 'month');
const minDate = dayjs().add(1, 'day');
if (selectedDate.isAfter(maxDate)) {
toast.error('Resume date cannot be more than 1 month from now');
return;
}
if (selectedDate.isBefore(minDate, 'day')) {
toast.error('Resume date must be at least 1 day from now');
return;
}
try {
setSubmitting(true);
await pauseWorkflow(requestId, levelId, reason.trim(), selectedDate.toDate());
toast.success('Workflow paused successfully');
// Wait for parent to refresh data before closing modal
// This ensures the UI shows updated pause status
if (onSuccess) {
await onSuccess();
}
setReason('');
setResumeDate(getDefaultResumeDate());
onClose();
} catch (error: any) {
console.error('Failed to pause workflow:', error);
toast.error(error?.response?.data?.error || error?.response?.data?.message || 'Failed to pause workflow');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
setReason('');
setResumeDate(getDefaultResumeDate());
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pause className="w-5 h-5 text-orange-600" />
Pause Workflow
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<p className="text-sm text-orange-800">
<strong>Note:</strong> Pausing will temporarily halt TAT calculations and notifications. The workflow will automatically resume on the selected date.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pause-reason" className="text-sm font-medium">
Reason for Pausing <span className="text-red-500">*</span>
</Label>
<Textarea
id="pause-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Enter the reason for pausing this workflow..."
className="min-h-[100px] text-sm"
disabled={submitting}
/>
<p className="text-xs text-gray-500">
{reason.length} / 1000 characters
</p>
</div>
<div className="space-y-2">
<Label htmlFor="resume-date" className="text-sm font-medium">
Resume Date <span className="text-red-500">*</span>
</Label>
<Input
id="resume-date"
type="date"
value={resumeDate}
onChange={(e) => setResumeDate(e.target.value)}
min={getMinResumeDate()}
max={getMaxResumeDate()}
className="text-sm"
disabled={submitting}
/>
<p className="text-xs text-gray-500">
Maximum 1 month from today. The workflow will automatically resume on this date.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || !reason.trim() || !resumeDate}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Pausing...
</>
) : (
<>
<Pause className="w-4 h-4 mr-2" />
Pause Workflow
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,114 +0,0 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2, Play } from 'lucide-react';
import { toast } from 'sonner';
import { resumeWorkflow } from '@/services/workflowApi';
interface ResumeModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
onSuccess?: () => void | Promise<void>;
}
export function ResumeModal({ isOpen, onClose, requestId, onSuccess }: ResumeModalProps) {
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
try {
setSubmitting(true);
await resumeWorkflow(requestId, notes.trim() || undefined);
toast.success('Workflow resumed successfully');
// Wait for parent to refresh data before closing modal
if (onSuccess) {
await onSuccess();
}
setNotes('');
onClose();
} catch (error: any) {
console.error('Failed to resume workflow:', error);
toast.error(error?.response?.data?.error || error?.response?.data?.message || 'Failed to resume workflow');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
setNotes('');
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Play className="w-5 h-5 text-green-600" />
Resume Workflow
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-800">
<strong>Note:</strong> Resuming will restart TAT calculations and notifications. The workflow will continue from where it was paused.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="resume-notes" className="text-sm font-medium">
Notes (Optional)
</Label>
<Textarea
id="resume-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any notes about why you're resuming this workflow..."
className="min-h-[100px] text-sm"
disabled={submitting}
/>
<p className="text-xs text-gray-500">
{notes.length} / 1000 characters
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting}
className="bg-green-600 hover:bg-green-700 text-white"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Resuming...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Resume Workflow
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,90 +0,0 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Loader2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { retriggerPause } from '@/services/workflowApi';
interface RetriggerPauseModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
approverName?: string;
onSuccess?: () => void | Promise<void>;
}
export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName, onSuccess }: RetriggerPauseModalProps) {
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
try {
setSubmitting(true);
await retriggerPause(requestId);
toast.success('Retrigger request sent to approver');
// Wait for parent to refresh data before closing modal
if (onSuccess) {
await onSuccess();
}
onClose();
} catch (error: any) {
console.error('Failed to retrigger pause:', error);
toast.error(error?.response?.data?.error || error?.response?.data?.message || 'Failed to send retrigger request');
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-orange-600" />
Request Resume
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-700 mb-4">
You are requesting the approver{approverName ? ` (${approverName})` : ''} to cancel the pause and resume work on this request.
</p>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<p className="text-sm text-orange-800">
A notification will be sent to the approver who paused this workflow, requesting them to resume it.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<AlertCircle className="w-4 h-4 mr-2" />
Send Request
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -9,7 +9,6 @@ import { createContext, useContext, useEffect, useState, ReactNode, useRef } fro
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
import { TokenManager, isTokenExpired } from '../utils/tokenManager';
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
import { tanflowLogout } from '../services/tanflowAuth';
interface User {
userId?: string;
@ -46,10 +45,8 @@ interface AuthProviderProps {
/**
* Check if running on localhost
* Note: Function reserved for future use
* @internal - Reserved for future use
*/
export const _isLocalhost = (): boolean => {
const isLocalhost = (): boolean => {
return (
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
@ -75,6 +72,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const forceLogout = sessionStorage.getItem('__force_logout__');
if (logoutFlag === 'true' || forceLogout === 'true') {
console.log('🔴 Logout flag detected - PREVENTING auto-authentication');
console.log('🔴 Clearing ALL authentication data and showing login screen');
// Remove flags
sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__');
@ -96,80 +96,54 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsLoading(false);
setError(null);
console.log('🔴 Logout complete - user should see login screen');
return;
}
// PRIORITY 2: Check if URL has logout parameter (from redirect)
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
console.log('🚪 Logout parameter detected in URL, clearing all tokens');
if (urlParams.has('logout') || urlParams.has('okta_logged_out')) {
console.log('🔴 Logout parameter in URL - clearing everything', {
hasLogout: urlParams.has('logout'),
hasOktaLoggedOut: urlParams.has('okta_logged_out'),
});
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();
// Don't clear sessionStorage completely - we might need logout flags
sessionStorage.clear();
setIsAuthenticated(false);
setUser(null);
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();
if (urlParams.has('okta_logged_out')) {
cleanParams.set('okta_logged_out', 'true');
}
if (urlParams.has('tanflow_logged_out')) {
cleanParams.set('tanflow_logged_out', 'true');
}
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
window.history.replaceState({}, document.title, newUrl);
return;
}
// 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
// before we can verify session with server
if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') {
// Don't check auth status here - let the callback handler do its job
// The callback handler will set isAuthenticated after successful token exchange
return;
}
// PRIORITY 4: Check authentication status
// PRIORITY 3: Check if this is a logout redirect by checking if all tokens are cleared
const token = TokenManager.getAccessToken();
const refreshToken = TokenManager.getRefreshToken();
const userData = TokenManager.getUserData();
const hasAuthData = token || refreshToken || userData;
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// If no auth data exists, we're likely after a logout - set unauthenticated state immediately
if (!hasAuthData) {
console.log('🔴 No auth data found - setting unauthenticated state');
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
// In production: Always verify with server (cookies are sent automatically)
// In development: Check local auth data first
if (isProductionMode) {
// Production: Verify session with server via httpOnly cookie
if (!isLoggingOut) {
checkAuthStatus();
} else {
setIsLoading(false);
}
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out
if (!isLoggingOut) {
checkAuthStatus();
} else {
// Development: If no auth data exists, user is not authenticated
if (!hasAuthData) {
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
if (!isLoggingOut) {
checkAuthStatus();
} else {
setIsLoading(false);
}
console.log('🔴 Skipping checkAuthStatus - logout in progress');
setIsLoading(false);
}
}, [isLoggingOut]);
@ -177,34 +151,20 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!isAuthenticated) return;
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
const checkAndRefresh = async () => {
if (isProductionMode) {
// In production, proactively refresh the session every 10 minutes
// The httpOnly cookie will be sent automatically
const token = TokenManager.getAccessToken();
if (token && isTokenExpired(token, 5)) {
// Token expires in less than 5 minutes, refresh it
try {
await refreshTokenSilently();
} catch (error) {
console.error('Silent refresh failed:', error);
}
} else {
// In development, check token expiration
const token = TokenManager.getAccessToken();
if (token && isTokenExpired(token, 5)) {
// Token expires in less than 5 minutes, refresh it
try {
await refreshTokenSilently();
} catch (error) {
console.error('Silent refresh failed:', error);
}
}
}
};
// Check every 10 minutes in production, 5 minutes in development
const intervalMs = isProductionMode ? 10 * 60 * 1000 : 5 * 60 * 1000;
const interval = setInterval(checkAndRefresh, intervalMs);
// Check every 5 minutes
const interval = setInterval(checkAndRefresh, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [isAuthenticated]);
@ -219,57 +179,24 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
}
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
callbackProcessedRef.current = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const errorParam = urlParams.get('error');
// Clean URL immediately to prevent re-running on re-renders
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) {
setError(new Error(`Authentication error: ${errorParam}`));
setIsLoading(false);
// Clear provider flag
sessionStorage.removeItem('auth_provider');
return;
}
if (!code) {
setIsLoading(false);
// Clear provider flag
sessionStorage.removeItem('auth_provider');
return;
}
@ -282,6 +209,12 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// This is the frontend callback URL, NOT the backend URL
// Backend will use this same URI when exchanging code with Okta
const redirectUri = `${window.location.origin}/login/callback`;
console.log('📥 Authorization Code Received:', {
code: code.substring(0, 10) + '...',
redirectUri,
fullUrl: window.location.href,
note: 'redirectUri is frontend URL (not backend) - must match Okta registration',
});
const result = await exchangeCodeForTokens(code, redirectUri);
@ -289,9 +222,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsAuthenticated(true);
setError(null);
// Clear provider flag after successful authentication
sessionStorage.removeItem('auth_provider');
// Clean URL after success
window.history.replaceState({}, document.title, '/');
} catch (err: any) {
@ -299,8 +229,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setError(err);
setIsAuthenticated(false);
setUser(null);
// Clear provider flag on error
sessionStorage.removeItem('auth_provider');
// Reset ref on error so user can retry if needed
callbackProcessedRef.current = false;
} finally {
@ -314,67 +242,22 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const checkAuthStatus = async () => {
// Don't check auth status if we're in the middle of logging out
if (isLoggingOut) {
console.log('🔴 Skipping checkAuthStatus - logout in progress');
setIsLoading(false);
return;
}
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
setIsLoading(true);
// PRODUCTION MODE: Verify session via httpOnly cookie
// The cookie is sent automatically with the request (withCredentials: true)
if (isProductionMode) {
const storedUser = TokenManager.getUserData();
// Try to get current user from server - this validates the httpOnly cookie
try {
const userData = await getCurrentUser();
setUser(userData);
TokenManager.setUserData(userData);
setIsAuthenticated(true);
} catch (error: any) {
// If 401, try to refresh the token (refresh token is also in httpOnly cookie)
if (error?.response?.status === 401) {
try {
await refreshTokenSilently();
// Retry getting user after refresh
const userData = await getCurrentUser();
setUser(userData);
TokenManager.setUserData(userData);
setIsAuthenticated(true);
} catch {
// Refresh failed - clear user data and show login
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
} else if (error?.isConnectionError) {
// Backend not reachable - use stored user data if available
if (storedUser) {
setUser(storedUser);
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
setUser(null);
}
} else {
// Other error - clear and show login
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
}
return;
}
// DEVELOPMENT MODE: Check local token
const token = TokenManager.getAccessToken();
const storedUser = TokenManager.getUserData();
console.log('🔍 Checking auth status:', { hasToken: !!token, hasUser: !!storedUser, isLoggingOut });
// If no token at all, user is not authenticated
if (!token) {
console.log('🔍 No token found - setting unauthenticated');
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
@ -461,12 +344,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const scope = 'openid profile email';
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
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?` +
`client_id=${clientId}&` +
@ -479,6 +359,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// This ensures Okta requires login even if a session still exists
if (isAfterLogout) {
authUrl += `&prompt=login`;
console.log('🔐 Adding prompt=login to force re-authentication after logout');
}
window.location.href = authUrl;
@ -489,85 +370,79 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
};
const logout = async () => {
console.log('🚪 LOGOUT FUNCTION CALLED - Starting logout process');
console.log('🚪 Current auth state:', { isAuthenticated, hasUser: !!user, isLoading });
try {
// 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();
// 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
// This must be set BEFORE clearing storage so it survives
sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true');
setIsLoggingOut(true);
console.log('🚪 Step 1: Resetting auth state...');
// Reset auth state FIRST to prevent any re-authentication
setIsAuthenticated(false);
setUser(null);
setError(null);
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
console.log('🚪 Step 1: Auth state reset complete');
// Call backend logout API to clear server-side session and httpOnly cookies
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
try {
console.log('🚪 Step 2: Calling backend logout API to clear httpOnly cookies...');
await logoutApi();
console.log('🚪 Backend logout API called successfully');
console.log('🚪 Step 2: Backend logout API completed - httpOnly cookies should be cleared');
} catch (err) {
console.error('🚪 Logout API error:', err);
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
// 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)
console.log('========================================');
console.log('LOGOUT - Clearing all authentication data');
console.log('========================================');
// Store id_token temporarily if we have it
let tempIdToken: string | null = null;
if (idToken) {
tempIdToken = idToken;
console.log('🚪 Preserving id_token for Okta logout:', tempIdToken.substring(0, 20) + '...');
}
// Clear tokens but preserve logout flags
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__');
const storedAuthProvider = sessionStorage.getItem('auth_provider');
// Clear all tokens EXCEPT id_token (we need it for provider logout)
// Note: We'll clear id_token after provider logout
// Clear tokens (but we'll restore id_token if needed)
// Use TokenManager.clearAll() but then restore logout flags
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 (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
}
console.log('🚪 Local storage cleared (logout flags preserved)');
// Final verification BEFORE redirect
console.log('🚪 Final verification - logout flags preserved:', {
logoutInProgress: sessionStorage.getItem('__logout_in_progress__'),
forceLogout: sessionStorage.getItem('__force_logout__'),
});
console.log('🚪 Clearing local session and redirecting to login...');
console.log('🚪 Using prompt=login on next auth to force re-authentication');
console.log('🚪 This will prevent auto-authentication even if Okta session exists');
// Small delay to ensure sessionStorage is written before redirect
await new Promise(resolve => setTimeout(resolve, 100));
// Handle provider-specific logout
if (authProvider === 'tanflow' && idToken) {
console.log('🚪 Initiating Tanflow logout...');
// Tanflow logout - redirect to Tanflow logout endpoint
// This will clear Tanflow session and redirect back to our app
try {
tanflowLogout(idToken);
// tanflowLogout will redirect, so we don't need to do anything else here
return;
} catch (tanflowLogoutError) {
console.error('🚪 Tanflow logout error:', tanflowLogoutError);
// Fall through to default logout flow
}
}
// OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
console.log('🚪 Using OKTA logout flow or fallback');
sessionStorage.removeItem('auth_provider');
// Clear id_token now since we're not using provider logout
if (idToken) {
TokenManager.clearAll(); // Clear id_token too
}
// Redirect directly to login page with flags
// The okta_logged_out flag will trigger prompt=login in the login() function
// This forces re-authentication even if Okta session still exists
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
@ -589,27 +464,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
};
const getAccessTokenSilently = async (): Promise<string | null> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production mode, tokens are in httpOnly cookies
// We can't access them directly, but API calls will include them automatically
if (isProductionMode) {
// If user is authenticated, return a placeholder indicating cookies are used
// The actual token is in httpOnly cookie and sent automatically with requests
if (isAuthenticated) {
return 'cookie-based-auth'; // Placeholder - actual auth via cookies
}
// Try to refresh the session
try {
await refreshTokenSilently();
return isAuthenticated ? 'cookie-based-auth' : null;
} catch {
return null;
}
}
// Development mode: tokens in localStorage
const token = TokenManager.getAccessToken();
if (token && !isTokenExpired(token)) {
return token;
@ -625,20 +479,10 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
};
const refreshTokenSilently = async (): Promise<void> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
const newToken = await refreshAccessToken();
// In production, refresh might not return token (it's in httpOnly cookie)
// but if the call succeeded, the session is valid
if (isProductionMode) {
// Session refreshed via cookies
return;
}
if (newToken) {
// Token refreshed successfully (development mode)
// Token refreshed successfully
return;
}
throw new Error('Failed to refresh token');
@ -666,10 +510,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
/**
* Auth0-based Auth Provider (for production)
* Note: Reserved for future use when Auth0 integration is needed
* @internal - Reserved for future use
*/
export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
function Auth0AuthProvider({ children }: { children: ReactNode }) {
return (
<Auth0Provider
domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
@ -677,8 +519,11 @@ export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
authorizationParams={{
redirect_uri: window.location.origin + '/login/callback',
}}
onRedirectCallback={(_appState) => {
// Auth0 redirect callback handled
onRedirectCallback={(appState) => {
console.log('Auth0 Redirect Callback:', {
appState,
returnTo: appState?.returnTo || window.location.pathname,
});
}}
>
<Auth0ContextWrapper>{children}</Auth0ContextWrapper>
@ -686,7 +531,6 @@ export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
);
}
/**
* Wrapper to convert Auth0 hook to our context format
*/

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

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

View File

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

View File

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

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