Compare commits
No commits in common. "main" and "dev_branch" have entirely different histories.
main
...
dev_branch
@ -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
3
Attributions.md
Normal 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).
|
||||||
@ -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
258
DetailedReports_Analysis.md
Normal 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
|
||||||
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
# Flow Deletion Guide - Complete Removal
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This guide explains how to completely remove a flow type from the application. The architecture ensures that **deleting a flow folder removes ALL related code** with zero dependencies remaining.
|
|
||||||
|
|
||||||
## Architecture Guarantee
|
|
||||||
|
|
||||||
✅ **Each flow folder is completely self-contained**
|
|
||||||
- All components, screens, hooks, services, utils, types are in the flow folder
|
|
||||||
- No dependencies on the flow folder from outside (except the registry)
|
|
||||||
- Deleting a folder = Removing all related functionality
|
|
||||||
|
|
||||||
## How to Delete a Flow Type
|
|
||||||
|
|
||||||
### Example: Removing Dealer Claim Flow
|
|
||||||
|
|
||||||
#### Step 1: Delete the Flow Folder
|
|
||||||
```bash
|
|
||||||
# Delete the entire dealer-claim folder
|
|
||||||
rm -rf src/flows/dealer-claim/
|
|
||||||
```
|
|
||||||
|
|
||||||
**What gets deleted:**
|
|
||||||
- ✅ `pages/RequestDetail.tsx` - Complete dealer claim detail screen
|
|
||||||
- ✅ All request detail components (OverviewTab, WorkflowTab, IOTab)
|
|
||||||
- ✅ All claim cards (5 cards)
|
|
||||||
- ✅ All modals (7 modals)
|
|
||||||
- ✅ Request creation wizard
|
|
||||||
- ✅ All future hooks, services, utils, types
|
|
||||||
|
|
||||||
#### Step 2: Update Flow Registry
|
|
||||||
```typescript
|
|
||||||
// src/flows/index.ts
|
|
||||||
|
|
||||||
// Remove import
|
|
||||||
// import * as DealerClaimFlow from './dealer-claim';
|
|
||||||
|
|
||||||
// Update FlowRegistry
|
|
||||||
export const FlowRegistry = {
|
|
||||||
CUSTOM: CustomFlow,
|
|
||||||
// DEALER_CLAIM: DealerClaimFlow, // REMOVED
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Update getRequestDetailScreen()
|
|
||||||
export function getRequestDetailScreen(flowType: RequestFlowType) {
|
|
||||||
switch (flowType) {
|
|
||||||
// case 'DEALER_CLAIM': // REMOVED
|
|
||||||
// return DealerClaimFlow.DealerClaimRequestDetail;
|
|
||||||
case 'CUSTOM':
|
|
||||||
default:
|
|
||||||
return CustomFlow.CustomRequestDetail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update other functions similarly
|
|
||||||
export function getOverviewTab(flowType: RequestFlowType) {
|
|
||||||
switch (flowType) {
|
|
||||||
// case 'DEALER_CLAIM': // REMOVED
|
|
||||||
// return DealerClaimFlow.DealerClaimOverviewTab;
|
|
||||||
case 'CUSTOM':
|
|
||||||
default:
|
|
||||||
return CustomFlow.CustomOverviewTab;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 3: Update Type Definitions (Optional)
|
|
||||||
```typescript
|
|
||||||
// src/utils/requestTypeUtils.ts
|
|
||||||
|
|
||||||
// Remove from type union
|
|
||||||
export type RequestFlowType = 'CUSTOM'; // 'DEALER_CLAIM' removed
|
|
||||||
|
|
||||||
// Remove detection function (optional - can keep for backward compatibility)
|
|
||||||
// export function isDealerClaimRequest(request: any): boolean { ... }
|
|
||||||
|
|
||||||
// Update getRequestFlowType()
|
|
||||||
export function getRequestFlowType(request: any): RequestFlowType {
|
|
||||||
// if (isDealerClaimRequest(request)) return 'DEALER_CLAIM'; // REMOVED
|
|
||||||
return 'CUSTOM';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 4: Update Navigation (If Needed)
|
|
||||||
```typescript
|
|
||||||
// src/utils/requestNavigation.ts
|
|
||||||
|
|
||||||
export function navigateToCreateRequest(
|
|
||||||
navigate: NavigateFunction,
|
|
||||||
flowType: RequestFlowType = 'CUSTOM'
|
|
||||||
): void {
|
|
||||||
// Remove dealer claim case
|
|
||||||
// if (flowType === 'DEALER_CLAIM') {
|
|
||||||
// return '/claim-management';
|
|
||||||
// }
|
|
||||||
return '/new-request';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 5: Remove Routes (If Needed)
|
|
||||||
```typescript
|
|
||||||
// src/App.tsx
|
|
||||||
|
|
||||||
// Remove dealer claim route
|
|
||||||
// <Route
|
|
||||||
// path="/claim-management"
|
|
||||||
// element={<ClaimManagementWizard ... />}
|
|
||||||
// />
|
|
||||||
```
|
|
||||||
|
|
||||||
**That's it!** All dealer claim code is completely removed.
|
|
||||||
|
|
||||||
## Verification Checklist
|
|
||||||
|
|
||||||
After deleting a flow, verify:
|
|
||||||
|
|
||||||
- [ ] Flow folder deleted
|
|
||||||
- [ ] FlowRegistry updated
|
|
||||||
- [ ] All `get*()` functions updated
|
|
||||||
- [ ] Type definitions updated (optional)
|
|
||||||
- [ ] Navigation updated (if needed)
|
|
||||||
- [ ] Routes removed (if needed)
|
|
||||||
- [ ] No broken imports
|
|
||||||
- [ ] Application compiles successfully
|
|
||||||
- [ ] No references to deleted flow in codebase
|
|
||||||
|
|
||||||
## What Happens When You Delete a Flow
|
|
||||||
|
|
||||||
### ✅ Removed
|
|
||||||
- Complete RequestDetail screen
|
|
||||||
- All flow-specific components
|
|
||||||
- All flow-specific modals
|
|
||||||
- All flow-specific cards
|
|
||||||
- Request creation wizard
|
|
||||||
- All flow-specific code
|
|
||||||
|
|
||||||
### ✅ Still Works
|
|
||||||
- Other flow types continue working
|
|
||||||
- Shared components remain
|
|
||||||
- Main RequestDetail router handles remaining flows
|
|
||||||
- Navigation for remaining flows
|
|
||||||
|
|
||||||
### ✅ No Orphaned Code
|
|
||||||
- No broken imports
|
|
||||||
- No dangling references
|
|
||||||
- No unused components
|
|
||||||
- Clean removal
|
|
||||||
|
|
||||||
## Current Flow Structure
|
|
||||||
|
|
||||||
### Custom Flow (`flows/custom/`)
|
|
||||||
**Contains:**
|
|
||||||
- `pages/RequestDetail.tsx` - Complete custom request detail screen
|
|
||||||
- `components/request-detail/` - Custom detail components
|
|
||||||
- `components/request-creation/` - Custom creation component
|
|
||||||
|
|
||||||
**To remove:** Delete `flows/custom/` folder and update registry
|
|
||||||
|
|
||||||
### Dealer Claim Flow (`flows/dealer-claim/`)
|
|
||||||
**Contains:**
|
|
||||||
- `pages/RequestDetail.tsx` - Complete dealer claim detail screen
|
|
||||||
- `components/request-detail/` - Dealer claim detail components
|
|
||||||
- `components/request-detail/claim-cards/` - 5 claim cards
|
|
||||||
- `components/request-detail/modals/` - 7 modals
|
|
||||||
- `components/request-creation/` - Claim management wizard
|
|
||||||
|
|
||||||
**To remove:** Delete `flows/dealer-claim/` folder and update registry
|
|
||||||
|
|
||||||
## Benefits of This Architecture
|
|
||||||
|
|
||||||
1. **True Modularity**: Each flow is independent
|
|
||||||
2. **Easy Removal**: Delete folder + update registry = Done
|
|
||||||
3. **No Side Effects**: Removing one flow doesn't affect others
|
|
||||||
4. **Clear Ownership**: Know exactly what belongs to which flow
|
|
||||||
5. **Maintainable**: All related code in one place
|
|
||||||
6. **Scalable**: Easy to add new flows
|
|
||||||
|
|
||||||
## Example: Complete Removal
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Delete folder
|
|
||||||
rm -rf src/flows/dealer-claim/
|
|
||||||
|
|
||||||
# 2. Update registry (remove 3 lines)
|
|
||||||
# 3. Update type (remove 1 line)
|
|
||||||
# 4. Done! All dealer claim code is gone.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Time to remove a flow:** ~2 minutes
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The architecture ensures that **deleting a flow folder removes ALL related code**. There are no dependencies, no orphaned files, and no cleanup needed. Each flow is a complete, self-contained module that can be added or removed independently.
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
# Complete Flow Segregation - Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document describes the **complete segregation** of request flows into dedicated folders. Each flow type (CUSTOM, DEALER_CLAIM) now has ALL its related components, hooks, services, utilities, and types in its own folder. Only truly shared components remain in the `shared/` folder.
|
|
||||||
|
|
||||||
## What Was Done
|
|
||||||
|
|
||||||
### 1. Created Complete Folder Structure
|
|
||||||
|
|
||||||
#### Custom Flow (`src/flows/custom/`)
|
|
||||||
```
|
|
||||||
custom/
|
|
||||||
├── components/
|
|
||||||
│ ├── request-detail/
|
|
||||||
│ │ ├── OverviewTab.tsx # Custom request overview
|
|
||||||
│ │ └── WorkflowTab.tsx # Custom request workflow
|
|
||||||
│ └── request-creation/
|
|
||||||
│ └── CreateRequest.tsx # Custom request creation
|
|
||||||
└── index.ts # Exports all custom components
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Dealer Claim Flow (`src/flows/dealer-claim/`)
|
|
||||||
```
|
|
||||||
dealer-claim/
|
|
||||||
├── components/
|
|
||||||
│ ├── request-detail/
|
|
||||||
│ │ ├── OverviewTab.tsx # Dealer claim overview
|
|
||||||
│ │ ├── WorkflowTab.tsx # Dealer claim workflow
|
|
||||||
│ │ ├── IOTab.tsx # IO management (dealer claim specific)
|
|
||||||
│ │ ├── claim-cards/ # All dealer claim cards
|
|
||||||
│ │ │ ├── ActivityInformationCard.tsx
|
|
||||||
│ │ │ ├── DealerInformationCard.tsx
|
|
||||||
│ │ │ ├── ProcessDetailsCard.tsx
|
|
||||||
│ │ │ ├── ProposalDetailsCard.tsx
|
|
||||||
│ │ │ └── RequestInitiatorCard.tsx
|
|
||||||
│ │ └── modals/ # All dealer claim modals
|
|
||||||
│ │ ├── CreditNoteSAPModal.tsx
|
|
||||||
│ │ ├── DealerCompletionDocumentsModal.tsx
|
|
||||||
│ │ ├── DealerProposalSubmissionModal.tsx
|
|
||||||
│ │ ├── DeptLeadIOApprovalModal.tsx
|
|
||||||
│ │ ├── EditClaimAmountModal.tsx
|
|
||||||
│ │ ├── EmailNotificationTemplateModal.tsx
|
|
||||||
│ │ └── InitiatorProposalApprovalModal.tsx
|
|
||||||
│ └── request-creation/
|
|
||||||
│ └── ClaimManagementWizard.tsx # Dealer claim creation
|
|
||||||
└── index.ts # Exports all dealer claim components
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Shared Components (`src/flows/shared/`)
|
|
||||||
```
|
|
||||||
shared/
|
|
||||||
└── components/
|
|
||||||
└── request-detail/
|
|
||||||
├── DocumentsTab.tsx # Used by all flows
|
|
||||||
├── ActivityTab.tsx # Used by all flows
|
|
||||||
├── WorkNotesTab.tsx # Used by all flows
|
|
||||||
├── SummaryTab.tsx # Used by all flows
|
|
||||||
├── RequestDetailHeader.tsx # Used by all flows
|
|
||||||
├── QuickActionsSidebar.tsx # Used by all flows
|
|
||||||
└── RequestDetailModals.tsx # Used by all flows
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Updated Flow Registry
|
|
||||||
|
|
||||||
The flow registry (`src/flows/index.ts`) now:
|
|
||||||
- Exports all flow modules
|
|
||||||
- Provides utility functions to get flow-specific components
|
|
||||||
- Includes `getCreateRequestComponent()` for request creation
|
|
||||||
- Exports `SharedComponents` for shared components
|
|
||||||
|
|
||||||
### 3. Updated RequestDetail Component
|
|
||||||
|
|
||||||
The `RequestDetail` component now:
|
|
||||||
- Uses flow registry to get flow-specific components
|
|
||||||
- Imports shared components from `SharedComponents`
|
|
||||||
- Dynamically loads appropriate tabs based on flow type
|
|
||||||
- Maintains backward compatibility
|
|
||||||
|
|
||||||
## File Organization Rules
|
|
||||||
|
|
||||||
### ✅ Flow-Specific Files → Flow Folders
|
|
||||||
|
|
||||||
**Custom Flow:**
|
|
||||||
- Custom request creation wizard
|
|
||||||
- Custom request detail tabs (Overview, Workflow)
|
|
||||||
- Custom request hooks (future)
|
|
||||||
- Custom request services (future)
|
|
||||||
- Custom request utilities (future)
|
|
||||||
- Custom request types (future)
|
|
||||||
|
|
||||||
**Dealer Claim Flow:**
|
|
||||||
- Dealer claim creation wizard
|
|
||||||
- Dealer claim detail tabs (Overview, Workflow, IO)
|
|
||||||
- Dealer claim cards (Activity, Dealer, Process, Proposal, Initiator)
|
|
||||||
- Dealer claim modals (all 7 modals)
|
|
||||||
- Dealer claim hooks (future)
|
|
||||||
- Dealer claim services (future)
|
|
||||||
- Dealer claim utilities (future)
|
|
||||||
- Dealer claim types (future)
|
|
||||||
|
|
||||||
### ✅ Shared Files → Shared Folder
|
|
||||||
|
|
||||||
**Shared Components:**
|
|
||||||
- DocumentsTab (used by all flows)
|
|
||||||
- ActivityTab (used by all flows)
|
|
||||||
- WorkNotesTab (used by all flows)
|
|
||||||
- SummaryTab (used by all flows)
|
|
||||||
- RequestDetailHeader (used by all flows)
|
|
||||||
- QuickActionsSidebar (used by all flows)
|
|
||||||
- RequestDetailModals (used by all flows)
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Getting Flow-Specific Components
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getOverviewTab, getWorkflowTab, getCreateRequestComponent } from '@/flows';
|
|
||||||
import { getRequestFlowType } from '@/utils/requestTypeUtils';
|
|
||||||
|
|
||||||
const flowType = getRequestFlowType(request);
|
|
||||||
const OverviewTab = getOverviewTab(flowType);
|
|
||||||
const WorkflowTab = getWorkflowTab(flowType);
|
|
||||||
const CreateRequest = getCreateRequestComponent(flowType);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Shared Components
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { SharedComponents } from '@/flows';
|
|
||||||
|
|
||||||
const { DocumentsTab, ActivityTab, WorkNotesTab, SummaryTab } = SharedComponents;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Direct Access to Flow Components
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { CustomFlow, DealerClaimFlow } from '@/flows';
|
|
||||||
|
|
||||||
// Custom flow
|
|
||||||
<CustomFlow.CustomOverviewTab {...props} />
|
|
||||||
<CustomFlow.CustomCreateRequest {...props} />
|
|
||||||
|
|
||||||
// Dealer claim flow
|
|
||||||
<DealerClaimFlow.DealerClaimOverviewTab {...props} />
|
|
||||||
<DealerClaimFlow.IOTab {...props} />
|
|
||||||
<DealerClaimFlow.ClaimManagementWizard {...props} />
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Complete Segregation**: Each flow is completely isolated
|
|
||||||
2. **Easy Navigation**: All files for a flow type are in one place
|
|
||||||
3. **Maintainability**: Changes to one flow don't affect others
|
|
||||||
4. **Scalability**: Easy to add new flow types
|
|
||||||
5. **Clarity**: Clear separation between flow-specific and shared code
|
|
||||||
6. **Type Safety**: TypeScript ensures correct usage
|
|
||||||
|
|
||||||
## Next Steps (Future Enhancements)
|
|
||||||
|
|
||||||
1. **Move Flow-Specific Hooks**
|
|
||||||
- Custom hooks → `flows/custom/hooks/`
|
|
||||||
- Dealer claim hooks → `flows/dealer-claim/hooks/`
|
|
||||||
|
|
||||||
2. **Move Flow-Specific Services**
|
|
||||||
- Custom services → `flows/custom/services/`
|
|
||||||
- Dealer claim services → `flows/dealer-claim/services/`
|
|
||||||
|
|
||||||
3. **Move Flow-Specific Utilities**
|
|
||||||
- Custom utilities → `flows/custom/utils/`
|
|
||||||
- Dealer claim utilities → `flows/dealer-claim/utils/`
|
|
||||||
|
|
||||||
4. **Move Flow-Specific Types**
|
|
||||||
- Custom types → `flows/custom/types/`
|
|
||||||
- Dealer claim types → `flows/dealer-claim/types/`
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### Custom Flow
|
|
||||||
- `src/flows/custom/components/request-detail/OverviewTab.tsx`
|
|
||||||
- `src/flows/custom/components/request-detail/WorkflowTab.tsx`
|
|
||||||
- `src/flows/custom/components/request-creation/CreateRequest.tsx`
|
|
||||||
- `src/flows/custom/index.ts` (updated)
|
|
||||||
|
|
||||||
### Dealer Claim Flow
|
|
||||||
- `src/flows/dealer-claim/components/request-detail/OverviewTab.tsx`
|
|
||||||
- `src/flows/dealer-claim/components/request-detail/WorkflowTab.tsx`
|
|
||||||
- `src/flows/dealer-claim/components/request-detail/IOTab.tsx`
|
|
||||||
- `src/flows/dealer-claim/components/request-detail/claim-cards/index.ts`
|
|
||||||
- `src/flows/dealer-claim/components/request-detail/modals/index.ts`
|
|
||||||
- `src/flows/dealer-claim/components/request-creation/ClaimManagementWizard.tsx`
|
|
||||||
- `src/flows/dealer-claim/index.ts` (updated)
|
|
||||||
|
|
||||||
### Shared Components
|
|
||||||
- `src/flows/shared/components/request-detail/DocumentsTab.tsx`
|
|
||||||
- `src/flows/shared/components/request-detail/ActivityTab.tsx`
|
|
||||||
- `src/flows/shared/components/request-detail/WorkNotesTab.tsx`
|
|
||||||
- `src/flows/shared/components/request-detail/SummaryTab.tsx`
|
|
||||||
- `src/flows/shared/components/request-detail/RequestDetailHeader.tsx`
|
|
||||||
- `src/flows/shared/components/request-detail/QuickActionsSidebar.tsx`
|
|
||||||
- `src/flows/shared/components/request-detail/RequestDetailModals.tsx`
|
|
||||||
- `src/flows/shared/components/index.ts` (updated)
|
|
||||||
|
|
||||||
### Registry
|
|
||||||
- `src/flows/index.ts` (updated with new structure)
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
- `src/pages/RequestDetail/RequestDetail.tsx` - Uses new flow structure
|
|
||||||
- `src/flows/README.md` - Updated with complete segregation documentation
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The complete segregation is now in place. Each flow type has its own dedicated folder with all related components. This makes it easy to:
|
|
||||||
- Find all files related to a specific flow type
|
|
||||||
- Maintain and update flow-specific code
|
|
||||||
- Add new flow types without affecting existing ones
|
|
||||||
- Understand what is shared vs. flow-specific
|
|
||||||
|
|
||||||
The architecture is now truly modular and plug-and-play!
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
# Flow Structure at Source Level - Complete Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Flow folders are now at the **`src/` level** for maximum visibility and easy removal. This makes it immediately clear what flows exist and makes deletion trivial.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── custom/ # ✅ Custom Request Flow
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── request-detail/ # Custom detail components
|
|
||||||
│ │ └── request-creation/ # Custom creation component
|
|
||||||
│ ├── pages/
|
|
||||||
│ │ └── RequestDetail.tsx # Complete custom request detail screen
|
|
||||||
│ └── index.ts # Exports all custom components
|
|
||||||
│
|
|
||||||
├── dealer-claim/ # ✅ Dealer Claim Flow
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── request-detail/ # Dealer claim detail components
|
|
||||||
│ │ │ ├── claim-cards/ # 5 claim cards
|
|
||||||
│ │ │ └── modals/ # 7 modals
|
|
||||||
│ │ └── request-creation/ # Claim management wizard
|
|
||||||
│ ├── pages/
|
|
||||||
│ │ └── RequestDetail.tsx # Complete dealer claim detail screen
|
|
||||||
│ └── index.ts # Exports all dealer claim components
|
|
||||||
│
|
|
||||||
├── shared/ # ✅ Shared Components
|
|
||||||
│ └── components/
|
|
||||||
│ └── request-detail/ # Components used by all flows
|
|
||||||
│
|
|
||||||
└── flows.ts # ✅ Flow registry and routing
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Benefits
|
|
||||||
|
|
||||||
### 1. Maximum Visibility
|
|
||||||
- Flow folders are directly visible at `src/` level
|
|
||||||
- No nested paths to navigate
|
|
||||||
- Clear separation from other code
|
|
||||||
|
|
||||||
### 2. Easy Removal
|
|
||||||
- Delete `src/custom/` → All custom code gone
|
|
||||||
- Delete `src/dealer-claim/` → All dealer claim code gone
|
|
||||||
- Update `src/flows.ts` → Done!
|
|
||||||
|
|
||||||
### 3. Complete Self-Containment
|
|
||||||
- Each flow folder contains ALL its code
|
|
||||||
- No dependencies outside the folder (except registry)
|
|
||||||
- Future hooks, services, utils, types go in flow folders
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
### Importing Flow Components
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// From flow registry
|
|
||||||
import { getRequestDetailScreen, CustomFlow, DealerClaimFlow } from '@/flows';
|
|
||||||
|
|
||||||
// Direct from flow folders
|
|
||||||
import { CustomRequestDetail } from '@/custom';
|
|
||||||
import { DealerClaimRequestDetail } from '@/dealer-claim';
|
|
||||||
|
|
||||||
// Shared components
|
|
||||||
import { SharedComponents } from '@/shared/components';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Main RequestDetail Router
|
|
||||||
|
|
||||||
The main `src/pages/RequestDetail/RequestDetail.tsx` routes to flow-specific screens:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const flowType = getRequestFlowType(apiRequest);
|
|
||||||
const RequestDetailScreen = getRequestDetailScreen(flowType);
|
|
||||||
return <RequestDetailScreen {...props} />;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deleting a Flow
|
|
||||||
|
|
||||||
### Step 1: Delete Folder
|
|
||||||
```bash
|
|
||||||
# Delete entire flow folder
|
|
||||||
rm -rf src/dealer-claim/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Update Registry
|
|
||||||
```typescript
|
|
||||||
// src/flows.ts
|
|
||||||
// Remove: import * as DealerClaimFlow from './dealer-claim';
|
|
||||||
// Remove: DEALER_CLAIM: DealerClaimFlow,
|
|
||||||
// Update: getRequestDetailScreen() to remove dealer claim case
|
|
||||||
```
|
|
||||||
|
|
||||||
**That's it!** All dealer claim code is completely removed.
|
|
||||||
|
|
||||||
## File Locations
|
|
||||||
|
|
||||||
### Custom Flow (`src/custom/`)
|
|
||||||
- `pages/RequestDetail.tsx` - Complete custom request detail screen
|
|
||||||
- `components/request-detail/OverviewTab.tsx`
|
|
||||||
- `components/request-detail/WorkflowTab.tsx`
|
|
||||||
- `components/request-creation/CreateRequest.tsx`
|
|
||||||
- `index.ts` - Exports all custom components
|
|
||||||
|
|
||||||
### Dealer Claim Flow (`src/dealer-claim/`)
|
|
||||||
- `pages/RequestDetail.tsx` - Complete dealer claim detail screen
|
|
||||||
- `components/request-detail/OverviewTab.tsx`
|
|
||||||
- `components/request-detail/WorkflowTab.tsx`
|
|
||||||
- `components/request-detail/IOTab.tsx`
|
|
||||||
- `components/request-detail/claim-cards/` - 5 cards
|
|
||||||
- `components/request-detail/modals/` - 7 modals
|
|
||||||
- `components/request-creation/ClaimManagementWizard.tsx`
|
|
||||||
- `index.ts` - Exports all dealer claim components
|
|
||||||
|
|
||||||
### Shared Components (`src/shared/`)
|
|
||||||
- `components/request-detail/DocumentsTab.tsx`
|
|
||||||
- `components/request-detail/ActivityTab.tsx`
|
|
||||||
- `components/request-detail/WorkNotesTab.tsx`
|
|
||||||
- `components/request-detail/SummaryTab.tsx`
|
|
||||||
- `components/request-detail/RequestDetailHeader.tsx`
|
|
||||||
- `components/request-detail/QuickActionsSidebar.tsx`
|
|
||||||
- `components/request-detail/RequestDetailModals.tsx`
|
|
||||||
- `components/index.ts` - Exports all shared components
|
|
||||||
|
|
||||||
### Flow Registry (`src/flows.ts`)
|
|
||||||
- FlowRegistry mapping
|
|
||||||
- `getRequestDetailScreen()` - Routes to flow-specific screens
|
|
||||||
- `getOverviewTab()` - Gets flow-specific overview tabs
|
|
||||||
- `getWorkflowTab()` - Gets flow-specific workflow tabs
|
|
||||||
- `getCreateRequestComponent()` - Gets flow-specific creation components
|
|
||||||
|
|
||||||
## Import Examples
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Flow registry
|
|
||||||
import { getRequestDetailScreen } from '@/flows';
|
|
||||||
|
|
||||||
// Direct flow imports
|
|
||||||
import { CustomRequestDetail } from '@/custom';
|
|
||||||
import { DealerClaimRequestDetail } from '@/dealer-claim';
|
|
||||||
|
|
||||||
// Shared components
|
|
||||||
import { SharedComponents } from '@/shared/components';
|
|
||||||
const { DocumentsTab, ActivityTab } = SharedComponents;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a New Flow
|
|
||||||
|
|
||||||
1. **Create folder**: `src/vendor-payment/`
|
|
||||||
2. **Create structure**:
|
|
||||||
```
|
|
||||||
src/vendor-payment/
|
|
||||||
├── components/
|
|
||||||
│ ├── request-detail/
|
|
||||||
│ └── request-creation/
|
|
||||||
├── pages/
|
|
||||||
│ └── RequestDetail.tsx
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
3. **Update `src/flows.ts`**:
|
|
||||||
```typescript
|
|
||||||
import * as VendorPaymentFlow from './vendor-payment';
|
|
||||||
|
|
||||||
export const FlowRegistry = {
|
|
||||||
CUSTOM: CustomFlow,
|
|
||||||
DEALER_CLAIM: DealerClaimFlow,
|
|
||||||
VENDOR_PAYMENT: VendorPaymentFlow,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The architecture is now **completely modular at the source level**. Flow folders are directly under `src/` for maximum visibility, easy navigation, and trivial removal. Each flow is a complete, self-contained module.
|
|
||||||
@ -1,257 +0,0 @@
|
|||||||
# Modular Request Flow Architecture - Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document summarizes the implementation of a modular, plug-and-play architecture for handling different request flow types (CUSTOM and DEALER_CLAIM) in the application.
|
|
||||||
|
|
||||||
## What Was Implemented
|
|
||||||
|
|
||||||
### 1. Request Type Detection Utilities (`src/utils/requestTypeUtils.ts`)
|
|
||||||
|
|
||||||
Created centralized utilities for detecting and handling different request types:
|
|
||||||
|
|
||||||
- `isCustomRequest(request)` - Checks if a request is a custom request
|
|
||||||
- `isDealerClaimRequest(request)` - Checks if a request is a dealer claim request
|
|
||||||
- `getRequestFlowType(request)` - Returns the flow type ('CUSTOM' | 'DEALER_CLAIM')
|
|
||||||
- `getRequestDetailRoute(requestId, request?)` - Gets the appropriate route for request detail
|
|
||||||
- `getCreateRequestRoute(flowType)` - Gets the route for creating a new request
|
|
||||||
|
|
||||||
### 2. Global Navigation Utility (`src/utils/requestNavigation.ts`)
|
|
||||||
|
|
||||||
Created a single point of navigation logic for all request-related routes:
|
|
||||||
|
|
||||||
- `navigateToRequest(options)` - Main navigation function that handles:
|
|
||||||
- Draft requests (routes to edit)
|
|
||||||
- Different flow types
|
|
||||||
- Status-based routing
|
|
||||||
- `navigateToCreateRequest(navigate, flowType)` - Navigate to create request based on flow type
|
|
||||||
- `createRequestNavigationHandler(navigate)` - Factory function for creating navigation handlers
|
|
||||||
|
|
||||||
### 3. Modular Flow Structure (`src/flows/`)
|
|
||||||
|
|
||||||
Created a modular folder structure for different request flows:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/flows/
|
|
||||||
├── custom/
|
|
||||||
│ └── index.ts # Exports Custom flow components
|
|
||||||
├── dealer-claim/
|
|
||||||
│ └── index.ts # Exports Dealer Claim flow components
|
|
||||||
├── shared/
|
|
||||||
│ └── components/ # Shared components (for future use)
|
|
||||||
└── index.ts # Flow registry and utilities
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow Registry** (`src/flows/index.ts`):
|
|
||||||
- `FlowRegistry` - Maps flow types to their modules
|
|
||||||
- `getFlowModule(flowType)` - Gets the flow module for a type
|
|
||||||
- `getOverviewTab(flowType)` - Gets the appropriate overview tab component
|
|
||||||
- `getWorkflowTab(flowType)` - Gets the appropriate workflow tab component
|
|
||||||
|
|
||||||
### 4. Updated RequestDetail Component
|
|
||||||
|
|
||||||
Modified `src/pages/RequestDetail/RequestDetail.tsx` to:
|
|
||||||
- Use flow type detection instead of hardcoded checks
|
|
||||||
- Dynamically load appropriate components based on flow type
|
|
||||||
- Support plug-and-play architecture for different flows
|
|
||||||
|
|
||||||
**Key Changes**:
|
|
||||||
- Replaced `isClaimManagementRequest()` with `getRequestFlowType()`
|
|
||||||
- Uses `getOverviewTab()` and `getWorkflowTab()` to get flow-specific components
|
|
||||||
- Maintains backward compatibility with existing components
|
|
||||||
|
|
||||||
### 5. Updated Navigation Throughout App
|
|
||||||
|
|
||||||
Updated all request card click handlers to use the global navigation utility:
|
|
||||||
|
|
||||||
**Files Updated**:
|
|
||||||
- `src/App.tsx` - Main `handleViewRequest` function
|
|
||||||
- `src/pages/ApproverPerformance/components/ApproverPerformanceRequestList.tsx`
|
|
||||||
- `src/pages/DetailedReports/DetailedReports.tsx`
|
|
||||||
|
|
||||||
All navigation now goes through `navigateToRequest()` for consistency.
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
### For Developers
|
|
||||||
|
|
||||||
### 1. Navigating to a Request
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { navigateToRequest } from '@/utils/requestNavigation';
|
|
||||||
|
|
||||||
// In a component with navigate function
|
|
||||||
navigateToRequest({
|
|
||||||
requestId: 'REQ-123',
|
|
||||||
requestTitle: 'My Request',
|
|
||||||
status: 'pending',
|
|
||||||
request: requestObject, // Optional: helps determine flow type
|
|
||||||
navigate: navigate,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Getting Flow-Specific Components
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getOverviewTab, getWorkflowTab } from '@/flows';
|
|
||||||
import { getRequestFlowType } from '@/utils/requestTypeUtils';
|
|
||||||
|
|
||||||
const flowType = getRequestFlowType(request);
|
|
||||||
const OverviewTab = getOverviewTab(flowType);
|
|
||||||
const WorkflowTab = getWorkflowTab(flowType);
|
|
||||||
|
|
||||||
// Use in JSX
|
|
||||||
<OverviewTab {...props} />
|
|
||||||
<WorkflowTab {...props} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Detecting Request Type
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
getRequestFlowType,
|
|
||||||
isCustomRequest,
|
|
||||||
isDealerClaimRequest
|
|
||||||
} from '@/utils/requestTypeUtils';
|
|
||||||
|
|
||||||
// Check specific type
|
|
||||||
if (isDealerClaimRequest(request)) {
|
|
||||||
// Handle dealer claim specific logic
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get flow type
|
|
||||||
const flowType = getRequestFlowType(request); // 'CUSTOM' | 'DEALER_CLAIM'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a New Flow Type
|
|
||||||
|
|
||||||
To add a new flow type (e.g., "VENDOR_PAYMENT"):
|
|
||||||
|
|
||||||
### Step 1: Update Type Definitions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/utils/requestTypeUtils.ts
|
|
||||||
export type RequestFlowType = 'CUSTOM' | 'DEALER_CLAIM' | 'VENDOR_PAYMENT';
|
|
||||||
|
|
||||||
export function isVendorPaymentRequest(request: any): boolean {
|
|
||||||
// Add detection logic
|
|
||||||
return request.workflowType === 'VENDOR_PAYMENT';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRequestFlowType(request: any): RequestFlowType {
|
|
||||||
if (isVendorPaymentRequest(request)) return 'VENDOR_PAYMENT';
|
|
||||||
if (isDealerClaimRequest(request)) return 'DEALER_CLAIM';
|
|
||||||
return 'CUSTOM';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create Flow Folder
|
|
||||||
|
|
||||||
```
|
|
||||||
src/flows/vendor-payment/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/flows/vendor-payment/index.ts
|
|
||||||
export { VendorPaymentOverviewTab } from '@/pages/RequestDetail/components/tabs/VendorPaymentOverviewTab';
|
|
||||||
export { VendorPaymentWorkflowTab } from '@/pages/RequestDetail/components/tabs/VendorPaymentWorkflowTab';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Update Flow Registry
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/flows/index.ts
|
|
||||||
import * as VendorPaymentFlow from './vendor-payment';
|
|
||||||
|
|
||||||
export const FlowRegistry = {
|
|
||||||
CUSTOM: CustomFlow,
|
|
||||||
DEALER_CLAIM: DealerClaimFlow,
|
|
||||||
VENDOR_PAYMENT: VendorPaymentFlow,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function getOverviewTab(flowType: RequestFlowType) {
|
|
||||||
switch (flowType) {
|
|
||||||
case 'VENDOR_PAYMENT':
|
|
||||||
return VendorPaymentFlow.VendorPaymentOverviewTab;
|
|
||||||
// ... existing cases
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Create Components
|
|
||||||
|
|
||||||
Create the flow-specific components in `src/pages/RequestDetail/components/tabs/`:
|
|
||||||
- `VendorPaymentOverviewTab.tsx`
|
|
||||||
- `VendorPaymentWorkflowTab.tsx`
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Modularity**: Each flow type is isolated in its own folder
|
|
||||||
2. **Maintainability**: Changes to one flow don't affect others
|
|
||||||
3. **Scalability**: Easy to add new flow types
|
|
||||||
4. **Consistency**: Single navigation utility ensures consistent routing
|
|
||||||
5. **Type Safety**: TypeScript ensures correct usage
|
|
||||||
6. **Reusability**: Shared components can be used across flows
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
### Backward Compatibility
|
|
||||||
|
|
||||||
- All existing code continues to work
|
|
||||||
- `isClaimManagementRequest()` still works (now uses `isDealerClaimRequest()` internally)
|
|
||||||
- Existing components are preserved and work as before
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
|
|
||||||
None. This is a non-breaking enhancement.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
To test the new architecture:
|
|
||||||
|
|
||||||
1. **Test Custom Requests**:
|
|
||||||
- Create a custom request
|
|
||||||
- Navigate to its detail page
|
|
||||||
- Verify correct components are loaded
|
|
||||||
|
|
||||||
2. **Test Dealer Claim Requests**:
|
|
||||||
- Create a dealer claim request
|
|
||||||
- Navigate to its detail page
|
|
||||||
- Verify dealer claim-specific components are loaded
|
|
||||||
|
|
||||||
3. **Test Navigation**:
|
|
||||||
- Click request cards from various pages
|
|
||||||
- Verify navigation works correctly
|
|
||||||
- Test draft requests (should route to edit)
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Add flow-specific validation rules
|
|
||||||
- [ ] Add flow-specific API endpoints
|
|
||||||
- [ ] Add flow-specific permissions
|
|
||||||
- [ ] Add flow-specific analytics
|
|
||||||
- [ ] Add flow-specific notifications
|
|
||||||
- [ ] Create shared request card component
|
|
||||||
- [ ] Add flow-specific creation wizards
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
1. `src/utils/requestTypeUtils.ts` - Request type detection utilities
|
|
||||||
2. `src/utils/requestNavigation.ts` - Global navigation utility
|
|
||||||
3. `src/flows/custom/index.ts` - Custom flow exports
|
|
||||||
4. `src/flows/dealer-claim/index.ts` - Dealer claim flow exports
|
|
||||||
5. `src/flows/index.ts` - Flow registry
|
|
||||||
6. `src/flows/shared/components/index.ts` - Shared components placeholder
|
|
||||||
7. `src/flows/README.md` - Flow architecture documentation
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
1. `src/pages/RequestDetail/RequestDetail.tsx` - Uses flow registry
|
|
||||||
2. `src/App.tsx` - Uses navigation utility
|
|
||||||
3. `src/pages/ApproverPerformance/components/ApproverPerformanceRequestList.tsx` - Uses navigation utility
|
|
||||||
4. `src/pages/DetailedReports/DetailedReports.tsx` - Uses navigation utility
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The modular architecture is now in place and ready for use. The system supports plug-and-play flow types, making it easy to add new request types in the future while maintaining clean separation of concerns.
|
|
||||||
611
README.md
611
README.md
@ -4,92 +4,43 @@ A modern, enterprise-grade approval and request management system built with Rea
|
|||||||
|
|
||||||
## 📋 Table of Contents
|
## 📋 Table of Contents
|
||||||
|
|
||||||
- [Features](#-features)
|
- [Features](#features)
|
||||||
- [Tech Stack](#️-tech-stack)
|
- [Tech Stack](#tech-stack)
|
||||||
- [Prerequisites](#-prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [Installation](#-installation)
|
- [Installation](#installation)
|
||||||
- [Development](#-development)
|
- [Development](#development)
|
||||||
- [Project Structure](#-project-structure)
|
- [Project Structure](#project-structure)
|
||||||
- [Available Scripts](#-available-scripts)
|
- [Available Scripts](#available-scripts)
|
||||||
- [Configuration](#️-configuration)
|
- [Configuration](#configuration)
|
||||||
- [Key Features Deep Dive](#-key-features-deep-dive)
|
- [Contributing](#contributing)
|
||||||
- [Troubleshooting](#-troubleshooting)
|
|
||||||
- [Contributing](#-contributing)
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
### 🔄 Dual Workflow System
|
- **🔄 Dual Workflow System**
|
||||||
- **Custom Request Workflow** - User-defined approvers, spectators, and workflow steps
|
- Custom Request Workflow with user-defined approvers
|
||||||
- **Claim Management Workflow** - 8-step predefined process for dealer claim management
|
- Claim Management Workflow (8-step predefined process)
|
||||||
- Flexible approval chains with multi-level approvers
|
|
||||||
- TAT (Turnaround Time) tracking at each approval level
|
|
||||||
|
|
||||||
### 📊 Comprehensive Dashboard
|
- **📊 Comprehensive Dashboard**
|
||||||
- Real-time statistics and metrics
|
- Real-time statistics and metrics
|
||||||
- High-priority alerts and critical request tracking
|
- High-priority alerts
|
||||||
- Recent activity feed with pagination
|
- Recent activity tracking
|
||||||
- Upcoming deadlines and SLA breach warnings
|
|
||||||
- Department-wise performance metrics
|
|
||||||
- Customizable KPI widgets (Admin only)
|
|
||||||
|
|
||||||
### 🎯 Request Management
|
- **🎯 Request Management**
|
||||||
- Create, track, and manage approval requests
|
- Create, track, and manage approval requests
|
||||||
- Document upload and management with file type validation
|
- Document upload and management
|
||||||
- Work notes and comprehensive audit trails
|
- Work notes and audit trails
|
||||||
- Spectator and stakeholder management
|
- Spectator and stakeholder management
|
||||||
- Request filtering, search, and export capabilities
|
|
||||||
- Detailed request lifecycle tracking
|
|
||||||
|
|
||||||
### 👥 Admin Control Panel
|
- **🎨 Modern UI/UX**
|
||||||
- **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
|
|
||||||
|
|
||||||
### 📈 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)
|
- Responsive design (mobile, tablet, desktop)
|
||||||
- Dark mode support
|
- Dark mode support
|
||||||
- Accessible components (WCAG compliant)
|
- Accessible components (WCAG compliant)
|
||||||
- Royal Enfield brand theming
|
- 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
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
@ -99,11 +50,8 @@ A modern, enterprise-grade approval and request management system built with Rea
|
|||||||
- **Styling:** Tailwind CSS 3.4+
|
- **Styling:** Tailwind CSS 3.4+
|
||||||
- **UI Components:** shadcn/ui + Radix UI
|
- **UI Components:** shadcn/ui + Radix UI
|
||||||
- **Icons:** Lucide React
|
- **Icons:** Lucide React
|
||||||
- **Notifications:** Sonner (Toast) + Web Push API (VAPID)
|
- **Notifications:** Sonner
|
||||||
- **Real-Time Communication:** Socket.IO Client
|
- **State Management:** React Hooks (useState, useMemo)
|
||||||
- **State Management:** React Hooks (useState, useMemo, useContext)
|
|
||||||
- **Authentication:** Okta SSO Integration
|
|
||||||
- **HTTP Client:** Axios
|
|
||||||
|
|
||||||
## 📦 Prerequisites
|
## 📦 Prerequisites
|
||||||
|
|
||||||
@ -113,20 +61,11 @@ A modern, enterprise-grade approval and request management system built with Rea
|
|||||||
|
|
||||||
## 🚀 Installation
|
## 🚀 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
|
### 1. Clone the repository
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd Re_Frontend_Code
|
cd Re_Figma_Code
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
### 2. Install dependencies
|
### 2. Install dependencies
|
||||||
@ -137,123 +76,36 @@ npm install
|
|||||||
|
|
||||||
### 3. Set up environment variables
|
### 3. Set up environment variables
|
||||||
|
|
||||||
#### Option A: Automated Setup (Recommended - Unix/Linux/Mac)
|
|
||||||
|
|
||||||
Run the setup script to automatically create environment files:
|
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
chmod +x setup-env.sh
|
cp .env.example .env
|
||||||
./setup-env.sh
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
This script will:
|
Edit `.env` with your configuration:
|
||||||
- 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`:
|
|
||||||
|
|
||||||
\`\`\`env
|
\`\`\`env
|
||||||
# Local Development Environment
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api/v1
|
VITE_APP_NAME=Royal Enfield Approval Portal
|
||||||
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
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**For Production:**
|
### 4. Move files to src directory
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
# Check environment file exists
|
# Create src directory structure
|
||||||
ls -la .env.local # Unix/Linux/Mac
|
mkdir -p src/components src/utils src/styles src/types
|
||||||
# or
|
|
||||||
Test-Path .env.local # Windows PowerShell
|
# Move existing files (you'll need to do this manually or run the migration script)
|
||||||
|
# The structure should match the project structure below
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## 💻 Development
|
## 💻 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
|
### Start development server
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
npm run dev
|
npm run dev
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
The application will open at `http://localhost:5173` (Vite default port)
|
The application will open at `http://localhost:3000`
|
||||||
|
|
||||||
> **Note:** If port 5173 is in use, Vite will automatically use the next available port.
|
|
||||||
|
|
||||||
### Build for production
|
### Build for production
|
||||||
|
|
||||||
@ -274,57 +126,28 @@ Re_Figma_Code/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── ui/ # Reusable UI components (40+)
|
│ │ ├── 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
|
│ │ ├── modals/ # Modal components
|
||||||
│ │ ├── participant/ # Participant management
|
│ │ ├── figma/ # Figma-specific components
|
||||||
│ │ ├── workflow/ # Workflow components
|
│ │ ├── Dashboard.tsx
|
||||||
│ │ └── workNote/ # Work notes/chat components
|
│ │ ├── Layout.tsx
|
||||||
│ ├── pages/
|
│ │ ├── ClaimManagementWizard.tsx
|
||||||
│ │ ├── Admin/ # Admin control panel
|
│ │ ├── NewRequestWizard.tsx
|
||||||
│ │ ├── ApproverPerformance/ # Approver analytics
|
│ │ ├── RequestDetail.tsx
|
||||||
│ │ ├── Auth/ # Authentication pages
|
│ │ ├── ClaimManagementDetail.tsx
|
||||||
│ │ ├── Dashboard/ # Main dashboard
|
│ │ ├── MyRequests.tsx
|
||||||
│ │ ├── 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
|
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ ├── utils/
|
│ ├── utils/
|
||||||
│ │ ├── socket.ts # Socket.IO utilities
|
│ │ ├── customRequestDatabase.ts
|
||||||
│ │ ├── pushNotifications.ts # Web push notifications
|
│ │ ├── claimManagementDatabase.ts
|
||||||
│ │ ├── slaTracker.ts # SLA calculation utilities
|
│ │ └── dealerDatabase.ts
|
||||||
│ │ └── ...
|
|
||||||
│ ├── contexts/
|
|
||||||
│ │ └── AuthContext.tsx # Authentication context
|
|
||||||
│ ├── styles/
|
│ ├── styles/
|
||||||
│ │ └── globals.css
|
│ │ └── globals.css
|
||||||
│ ├── types/
|
│ ├── types/
|
||||||
│ │ └── index.ts
|
│ │ └── index.ts # TypeScript type definitions
|
||||||
│ ├── App.tsx
|
│ ├── App.tsx
|
||||||
│ └── main.tsx
|
│ └── main.tsx
|
||||||
├── public/
|
├── public/ # Static assets
|
||||||
│ └── service-worker.js # Service worker for push notifications
|
├── .vscode/ # VS Code settings
|
||||||
├── .vscode/
|
|
||||||
├── index.html
|
├── index.html
|
||||||
├── vite.config.ts
|
├── vite.config.ts
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
@ -357,13 +180,9 @@ The project uses path aliases for cleaner imports:
|
|||||||
|
|
||||||
\`\`\`typescript
|
\`\`\`typescript
|
||||||
import { Button } from '@/components/ui/button';
|
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
|
### Tailwind CSS Customization
|
||||||
|
|
||||||
Custom Royal Enfield colors are defined in `tailwind.config.ts`:
|
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:
|
All environment variables must be prefixed with `VITE_` to be accessible in the app:
|
||||||
|
|
||||||
\`\`\`typescript
|
\`\`\`typescript
|
||||||
// Access environment variables
|
|
||||||
const apiUrl = import.meta.env.VITE_API_BASE_URL;
|
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:**
|
## 🔧 Next Steps
|
||||||
- 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`)
|
|
||||||
|
|
||||||
### Backend Integration
|
### 1. File Migration
|
||||||
|
|
||||||
To connect to the backend API:
|
Move existing files to the `src` directory:
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
# Test WebSocket connection
|
# Move App.tsx
|
||||||
# Open browser console and check for Socket.IO connection logs
|
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`
|
Create `src/main.tsx`:
|
||||||
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
|
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`typescript
|
||||||
# Check service worker registration
|
import React from 'react';
|
||||||
# Open DevTools > Application > Service Workers
|
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
|
\`\`\`typescript
|
||||||
# Option 1: Kill the process using the port
|
// Before
|
||||||
# Windows
|
import { Button } from './components/ui/button';
|
||||||
netstat -ano | findstr :5173
|
|
||||||
taskkill /PID <PID> /F
|
|
||||||
|
|
||||||
# Unix/Linux/Mac
|
// After
|
||||||
lsof -ti:5173 | xargs kill -9
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
# Option 2: Use a different port
|
|
||||||
npm run dev -- --port 3000
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
#### Environment Variables Not Loading
|
### 4. Backend Integration
|
||||||
|
|
||||||
1. Ensure variables are prefixed with `VITE_`
|
When ready to connect to a real API:
|
||||||
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
|
|
||||||
|
|
||||||
#### Backend Connection Issues
|
1. Create `src/services/api.ts` for API calls
|
||||||
|
2. Replace mock databases with API calls
|
||||||
1. Verify backend is running on the configured port
|
3. Add authentication layer
|
||||||
2. Check `VITE_API_BASE_URL` in `.env.local` matches backend URL
|
4. Implement error handling
|
||||||
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
|
|
||||||
|
|
||||||
## 🧪 Testing (Future Enhancement)
|
## 🧪 Testing (Future Enhancement)
|
||||||
|
|
||||||
|
|||||||
@ -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
193
ROLE_MIGRATION.md
Normal 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!
|
||||||
|
|
||||||
@ -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
339
USER_ROLE_MANAGEMENT.md
Normal 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! 🎯
|
||||||
|
|
||||||
@ -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
19
fix-imports.ps1
Normal 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
|
||||||
|
|
||||||
@ -2,8 +2,6 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
||||||
|
|||||||
53
migrate-files.ps1
Normal file
53
migrate-files.ps1
Normal 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
28
package-lock.json
generated
@ -52,9 +52,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.53.0",
|
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable-panels": "^2.1.0",
|
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"recharts": "^2.13.3",
|
"recharts": "^2.13.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
@ -6119,22 +6117,6 @@
|
|||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"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": {
|
"node_modules/react-router": {
|
||||||
"version": "7.9.4",
|
"version": "7.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",
|
||||||
|
|||||||
@ -57,9 +57,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.53.0",
|
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable-panels": "^2.1.0",
|
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"recharts": "^2.13.3",
|
"recharts": "^2.13.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
|
|||||||
97
setup-env.bat
Normal file
97
setup-env.bat
Normal 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
|
||||||
|
|
||||||
60
setup-env.sh
60
setup-env.sh
@ -15,13 +15,6 @@ VITE_API_BASE_URL=http://localhost:5000/api/v1
|
|||||||
|
|
||||||
# Base URL for direct file access (without /api/v1)
|
# Base URL for direct file access (without /api/v1)
|
||||||
VITE_BASE_URL=http://localhost:5000
|
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
|
EOF
|
||||||
echo "✅ Created .env.example"
|
echo "✅ Created .env.example"
|
||||||
}
|
}
|
||||||
@ -32,13 +25,6 @@ create_env_local() {
|
|||||||
# Local Development Environment
|
# Local Development Environment
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api/v1
|
VITE_API_BASE_URL=http://localhost:5000/api/v1
|
||||||
VITE_BASE_URL=http://localhost:5000
|
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
|
EOF
|
||||||
echo "✅ Created .env.local (for local development)"
|
echo "✅ Created .env.local (for local development)"
|
||||||
}
|
}
|
||||||
@ -51,55 +37,25 @@ create_env_production() {
|
|||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo ""
|
echo ""
|
||||||
read -p "Enter your PRODUCTION backend URL (e.g., https://api.yourcompany.com): " BACKEND_URL
|
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
|
if [ -z "$BACKEND_URL" ]; then
|
||||||
echo "⚠️ No backend URL provided. Creating template file..."
|
echo "⚠️ No backend URL provided. Creating template file..."
|
||||||
cat > .env.production << 'EOF'
|
cat > .env.production << 'EOF'
|
||||||
# Production Environment
|
# Production Environment
|
||||||
# IMPORTANT: Update these values with your actual production configuration
|
# IMPORTANT: Update these URLs with your actual deployed backend URL
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
VITE_API_BASE_URL=https://your-backend-url.com/api/v1
|
VITE_API_BASE_URL=https://your-backend-url.com/api/v1
|
||||||
VITE_BASE_URL=https://your-backend-url.com
|
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
|
EOF
|
||||||
else
|
else
|
||||||
|
# Remove trailing slash if present
|
||||||
|
BACKEND_URL=${BACKEND_URL%/}
|
||||||
|
|
||||||
cat > .env.production << EOF
|
cat > .env.production << EOF
|
||||||
# Production Environment
|
# Production Environment
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
VITE_API_BASE_URL=${BACKEND_URL}/api/v1
|
VITE_API_BASE_URL=${BACKEND_URL}/api/v1
|
||||||
VITE_BASE_URL=${BACKEND_URL}
|
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
|
EOF
|
||||||
echo "✅ Created .env.production with:"
|
echo "✅ Created .env.production with backend URL: ${BACKEND_URL}"
|
||||||
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"
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,7 +99,11 @@ echo " Set environment variables in your platform dashboard"
|
|||||||
echo " - If using Docker/VM:"
|
echo " - If using Docker/VM:"
|
||||||
echo " Ensure .env.production has correct URLs"
|
echo " Ensure .env.production has correct URLs"
|
||||||
echo ""
|
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 " - Add production frontend URL to CORS allowed origins"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📖 For detailed instructions, see: DEPLOYMENT_CONFIGURATION.md"
|
echo "📖 For detailed instructions, see: DEPLOYMENT_CONFIGURATION.md"
|
||||||
|
|||||||
420
src/App.tsx
420
src/App.tsx
@ -1,96 +1,37 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { PageLayout } from '@/components/layout/PageLayout';
|
||||||
import { Dashboard } from '@/pages/Dashboard';
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
import { OpenRequests } from '@/pages/OpenRequests';
|
import { OpenRequests } from '@/pages/OpenRequests';
|
||||||
import { ClosedRequests } from '@/pages/ClosedRequests';
|
import { ClosedRequests } from '@/pages/ClosedRequests';
|
||||||
import { RequestDetail } from '@/pages/RequestDetail';
|
import { RequestDetail } from '@/pages/RequestDetail';
|
||||||
import { SharedSummaries } from '@/pages/SharedSummaries/SharedSummaries';
|
|
||||||
import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail';
|
|
||||||
import { WorkNotes } from '@/pages/WorkNotes';
|
import { WorkNotes } from '@/pages/WorkNotes';
|
||||||
import { CreateRequest } from '@/pages/CreateRequest';
|
import { CreateRequest } from '@/pages/CreateRequest';
|
||||||
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard';
|
||||||
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
|
|
||||||
import { MyRequests } from '@/pages/MyRequests';
|
import { MyRequests } from '@/pages/MyRequests';
|
||||||
import { Requests } from '@/pages/Requests/Requests';
|
|
||||||
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
|
||||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
|
||||||
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
|
|
||||||
import { Profile } from '@/pages/Profile';
|
import { Profile } from '@/pages/Profile';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
import { Notifications } from '@/pages/Notifications';
|
import { Notifications } from '@/pages/Notifications';
|
||||||
import { DetailedReports } from '@/pages/DetailedReports';
|
import { DetailedReports } from '@/pages/DetailedReports';
|
||||||
import { Admin } from '@/pages/Admin';
|
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 { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||||
|
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||||
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
||||||
import { createClaimRequest } from '@/services/dealerClaimApi';
|
|
||||||
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
// Combined Request Database for backward compatibility
|
||||||
import { navigateToRequest } from '@/utils/requestNavigation';
|
// This combines both custom and claim management requests
|
||||||
// import { TokenManager } from '@/utils/tokenManager';
|
export const REQUEST_DATABASE: any = {
|
||||||
|
...CUSTOM_REQUEST_DATABASE,
|
||||||
|
...CLAIM_MANAGEMENT_DATABASE
|
||||||
|
};
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Main Application Routes Component
|
||||||
function AppRoutes({ onLogout }: AppProps) {
|
function AppRoutes({ onLogout }: AppProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -98,20 +39,6 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
|
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
|
||||||
const [selectedRequestId, setSelectedRequestId] = useState<string>('');
|
const [selectedRequestId, setSelectedRequestId] = useState<string>('');
|
||||||
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
|
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
|
||||||
const [managerModalOpen, setManagerModalOpen] = useState(false);
|
|
||||||
const [managerModalData, setManagerModalData] = useState<{
|
|
||||||
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
|
|
||||||
managers?: Array<{
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
displayName: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
department?: string;
|
|
||||||
}>;
|
|
||||||
message?: string;
|
|
||||||
pendingClaimData?: any;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Retrieve dynamic requests from localStorage on mount
|
// Retrieve dynamic requests from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -143,27 +70,20 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
navigate('/settings');
|
navigate('/settings');
|
||||||
return;
|
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);
|
setSelectedRequestId(requestId);
|
||||||
setSelectedRequestTitle(requestTitle || 'Unknown Request');
|
setSelectedRequestTitle(requestTitle || 'Unknown Request');
|
||||||
|
|
||||||
// Use global navigation utility for consistent routing
|
// Check if request is a draft - if so, route to edit form instead of detail view
|
||||||
navigateToRequest({
|
const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';
|
||||||
requestId,
|
if (isDraft) {
|
||||||
requestTitle,
|
navigate(`/edit-request/${requestId}`);
|
||||||
status,
|
} else {
|
||||||
request,
|
navigate(`/request/${requestId}`);
|
||||||
navigate,
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@ -184,14 +104,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If requestData has backendId, it means it came from the API flow (CreateRequest component)
|
// Regular custom request submission
|
||||||
// The hook already shows the toast, so we just navigate
|
|
||||||
if (requestData.backendId) {
|
|
||||||
navigate('/my-requests');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular custom request submission (old flow without API)
|
|
||||||
// Generate unique ID for the new custom request
|
// Generate unique ID for the new custom request
|
||||||
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
|
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
|
||||||
|
|
||||||
@ -292,10 +205,15 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
// Add to dynamic requests
|
// Add to dynamic requests
|
||||||
setDynamicRequests([...dynamicRequests, newCustomRequest]);
|
setDynamicRequests([...dynamicRequests, newCustomRequest]);
|
||||||
|
|
||||||
|
console.log('New custom request created:', newCustomRequest);
|
||||||
navigate('/my-requests');
|
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) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (action === 'approve') {
|
if (action === 'approve') {
|
||||||
@ -310,6 +228,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`${action} action completed with comment:`, comment);
|
||||||
setApprovalAction(null);
|
setApprovalAction(null);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@ -320,101 +239,58 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
setApprovalAction(null);
|
setApprovalAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClaimManagementSubmit = async (claimData: any, _selectedManagerEmail?: string) => {
|
const handleOpenModal = (modal: string) => {
|
||||||
try {
|
switch (modal) {
|
||||||
// Prepare payload for API
|
case 'work-note':
|
||||||
const payload = {
|
navigate(`/work-notes/${selectedRequestId}`);
|
||||||
activityName: claimData.activityName,
|
break;
|
||||||
activityType: claimData.activityType,
|
case 'internal-chat':
|
||||||
dealerCode: claimData.dealerCode,
|
toast.success('Internal Chat Opened', {
|
||||||
dealerName: claimData.dealerName,
|
description: 'Internal chat opened for request stakeholders.',
|
||||||
dealerEmail: claimData.dealerEmail || undefined,
|
});
|
||||||
dealerPhone: claimData.dealerPhone || undefined,
|
break;
|
||||||
dealerAddress: claimData.dealerAddress || undefined,
|
case 'approval-list':
|
||||||
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toISOString() : undefined,
|
toast.info('Approval List', {
|
||||||
location: claimData.location,
|
description: 'Detailed approval workflow would be displayed.',
|
||||||
requestDescription: claimData.requestDescription,
|
});
|
||||||
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
|
break;
|
||||||
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
|
case 'approve':
|
||||||
estimatedBudget: claimData.estimatedBudget || undefined,
|
setApprovalAction('approve');
|
||||||
approvers: claimData.approvers || [], // Pass approvers array
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call API to create claim request
|
const handleClaimManagementSubmit = (claimData: any) => {
|
||||||
const response = await createClaimRequest(payload);
|
|
||||||
|
|
||||||
// Validate response - ensure request was actually created successfully
|
|
||||||
if (!response || !response.request) {
|
|
||||||
throw new Error('Invalid response from server: Request object not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdRequest = response.request;
|
|
||||||
|
|
||||||
// Validate that we have at least one identifier (requestNumber or requestId)
|
|
||||||
if (!createdRequest.requestNumber && !createdRequest.requestId) {
|
|
||||||
throw new Error('Invalid response from server: Request identifier not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close manager modal if open
|
|
||||||
setManagerModalOpen(false);
|
|
||||||
setManagerModalData(null);
|
|
||||||
|
|
||||||
// Only show success toast if request was actually created successfully
|
|
||||||
toast.success('Claim Request Submitted', {
|
|
||||||
description: 'Your claim management request has been created successfully.',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate to the created request detail page using requestNumber
|
|
||||||
if (createdRequest.requestNumber) {
|
|
||||||
navigate(`/request/${createdRequest.requestNumber}`);
|
|
||||||
} else if (createdRequest.requestId) {
|
|
||||||
// Fallback to requestId if requestNumber is not available
|
|
||||||
navigate(`/request/${createdRequest.requestId}`);
|
|
||||||
} else {
|
|
||||||
// This should not happen due to validation above, but just in case
|
|
||||||
navigate('/my-requests');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[App] Error creating claim request:', error);
|
|
||||||
|
|
||||||
// Check for manager-related errors
|
|
||||||
const errorData = error?.response?.data;
|
|
||||||
const errorCode = errorData?.code || errorData?.error?.code;
|
|
||||||
|
|
||||||
if (errorCode === 'NO_MANAGER_FOUND') {
|
|
||||||
// Show modal for no manager found
|
|
||||||
setManagerModalData({
|
|
||||||
errorType: 'NO_MANAGER_FOUND',
|
|
||||||
message: errorData?.message || errorData?.error?.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
|
|
||||||
pendingClaimData: claimData,
|
|
||||||
});
|
|
||||||
setManagerModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
|
|
||||||
// Show modal with manager list for selection
|
|
||||||
const managers = errorData?.managers || errorData?.error?.managers || [];
|
|
||||||
setManagerModalData({
|
|
||||||
errorType: 'MULTIPLE_MANAGERS_FOUND',
|
|
||||||
managers: managers,
|
|
||||||
message: errorData?.message || errorData?.error?.message || 'Multiple managers found. Please select one.',
|
|
||||||
pendingClaimData: claimData,
|
|
||||||
});
|
|
||||||
setManagerModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other errors - show toast
|
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
|
|
||||||
toast.error('Failed to Submit Claim Request', {
|
|
||||||
description: errorMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the old code below for backward compatibility (local storage fallback)
|
|
||||||
// This can be removed once API integration is fully tested
|
|
||||||
/*
|
|
||||||
// Generate unique ID for the new claim request
|
// Generate unique ID for the new claim request
|
||||||
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
|
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
|
||||||
|
|
||||||
@ -606,24 +482,23 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
description: 'Your claim management request has been created successfully.',
|
description: 'Your claim management request has been created successfully.',
|
||||||
});
|
});
|
||||||
navigate('/my-requests');
|
navigate('/my-requests');
|
||||||
*/
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
|
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Auth Callback - Unified callback for both OKTA and Tanflow */}
|
{/* Auth Callback - Must be before other routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/login/callback"
|
path="/login/callback"
|
||||||
element={<AuthCallback />}
|
element={<AuthCallback />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
|
{/* Dashboard */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -632,67 +507,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin Routes Group with Shared Layout */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Outlet />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/admin/create-template" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/templates" element={<AdminTemplatesList />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Create Request from Admin Template (Dedicated Flow) */}
|
|
||||||
<Route
|
|
||||||
path="/create-admin-request/:templateId"
|
|
||||||
element={
|
|
||||||
<CreateAdminRequest />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Admin />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin Routes Group with Shared Layout */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Outlet />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/admin/create-template" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/templates" element={<AdminTemplatesList />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Create Request from Admin Template (Dedicated Flow) */}
|
|
||||||
<Route
|
|
||||||
path="/create-admin-request/:templateId"
|
|
||||||
element={
|
|
||||||
<CreateAdminRequest />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Admin />
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -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 */}
|
{/* My Requests */}
|
||||||
<Route
|
<Route
|
||||||
path="/my-requests"
|
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 */}
|
{/* Request Detail - requestId will be read from URL params */}
|
||||||
<Route
|
<Route
|
||||||
path="/request/:requestId"
|
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>
|
</Routes>
|
||||||
|
|
||||||
<Toaster
|
<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 */}
|
{/* Approval Action Modal */}
|
||||||
{approvalAction && (
|
{approvalAction && (
|
||||||
<ApprovalActionModal
|
<ApprovalActionModal
|
||||||
@ -921,6 +681,8 @@ interface MainAppProps {
|
|||||||
|
|
||||||
export default function App(props?: MainAppProps) {
|
export default function App(props?: MainAppProps) {
|
||||||
const { onLogout } = props || {};
|
const { onLogout } = props || {};
|
||||||
|
console.log('🟢 Main App component rendered');
|
||||||
|
console.log('🟢 onLogout prop received:', !!onLogout);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
@ -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';
|
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useAuth0 } from '@auth0/auth0-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -14,7 +14,14 @@ export function AuthDebugInfo({ isOpen, onClose }: AuthDebugInfoProps) {
|
|||||||
const { user, isAuthenticated, isLoading, error } = useAuth0();
|
const { user, isAuthenticated, isLoading, error } = useAuth0();
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [user, isAuthenticated, isLoading, error]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
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 { AIProviderSettings } from './AIProviderSettings';
|
||||||
import { AIFeatures } from './AIFeatures';
|
import { AIFeatures } from './AIFeatures';
|
||||||
import { AIParameters } from './AIParameters';
|
import { AIParameters } from './AIParameters';
|
||||||
@ -11,6 +11,10 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
interface AIConfigData {
|
interface AIConfigData {
|
||||||
aiEnabled: boolean;
|
aiEnabled: boolean;
|
||||||
|
aiProvider: 'claude' | 'openai' | 'gemini';
|
||||||
|
claudeApiKey: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
geminiApiKey: string;
|
||||||
aiRemarkGeneration: boolean;
|
aiRemarkGeneration: boolean;
|
||||||
maxRemarkChars: number;
|
maxRemarkChars: number;
|
||||||
}
|
}
|
||||||
@ -18,10 +22,19 @@ interface AIConfigData {
|
|||||||
export function AIConfig() {
|
export function AIConfig() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({
|
||||||
|
claude: false,
|
||||||
|
openai: false,
|
||||||
|
gemini: false
|
||||||
|
});
|
||||||
const [config, setConfig] = useState<AIConfigData>({
|
const [config, setConfig] = useState<AIConfigData>({
|
||||||
aiEnabled: true,
|
aiEnabled: true,
|
||||||
|
aiProvider: 'claude',
|
||||||
|
claudeApiKey: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
geminiApiKey: '',
|
||||||
aiRemarkGeneration: true,
|
aiRemarkGeneration: true,
|
||||||
maxRemarkChars: 2000
|
maxRemarkChars: 500
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -41,8 +54,12 @@ export function AIConfig() {
|
|||||||
|
|
||||||
setConfig({
|
setConfig({
|
||||||
aiEnabled: configMap['AI_ENABLED'] === 'true',
|
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',
|
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) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load AI configurations:', error);
|
console.error('Failed to load AI configurations:', error);
|
||||||
@ -59,8 +76,12 @@ export function AIConfig() {
|
|||||||
// Save all configurations
|
// Save all configurations
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
updateConfiguration('AI_ENABLED', config.aiEnabled.toString()),
|
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_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');
|
toast.success('AI configuration saved successfully');
|
||||||
@ -79,6 +100,19 @@ export function AIConfig() {
|
|||||||
setConfig(prev => ({ ...prev, ...updates }));
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-lg border-0 rounded-md">
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
@ -94,13 +128,13 @@ export function AIConfig() {
|
|||||||
<Card className="shadow-lg border-0 rounded-md">
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Sparkles className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold text-gray-900">AI Features Configuration</CardTitle>
|
<CardTitle className="text-lg font-semibold text-gray-900">AI Features Configuration</CardTitle>
|
||||||
<CardDescription className="text-sm text-gray-600">
|
<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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -108,7 +142,18 @@ export function AIConfig() {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<AIProviderSettings
|
<AIProviderSettings
|
||||||
aiEnabled={config.aiEnabled}
|
aiEnabled={config.aiEnabled}
|
||||||
|
aiProvider={config.aiProvider}
|
||||||
|
claudeApiKey={config.claudeApiKey}
|
||||||
|
openaiApiKey={config.openaiApiKey}
|
||||||
|
geminiApiKey={config.geminiApiKey}
|
||||||
|
showApiKeys={showApiKeys}
|
||||||
onAiEnabledChange={(enabled) => updateConfig({ aiEnabled: enabled })}
|
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 />
|
<Separator />
|
||||||
|
|||||||
@ -26,19 +26,19 @@ export function AIParameters({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="max-remark-chars" className="text-sm font-medium">
|
<Label htmlFor="max-remark-chars" className="text-sm font-medium">
|
||||||
Maximum Remark Length
|
Maximum Remark Characters
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="max-remark-chars"
|
id="max-remark-chars"
|
||||||
type="number"
|
type="number"
|
||||||
min="500"
|
min="100"
|
||||||
max="5000"
|
max="2000"
|
||||||
value={maxRemarkChars}
|
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"
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,25 +1,85 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 {
|
interface AIProviderSettingsProps {
|
||||||
aiEnabled: boolean;
|
aiEnabled: boolean;
|
||||||
|
aiProvider: 'claude' | 'openai' | 'gemini';
|
||||||
|
claudeApiKey: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
geminiApiKey: string;
|
||||||
|
showApiKeys: Record<string, boolean>;
|
||||||
onAiEnabledChange: (enabled: boolean) => void;
|
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({
|
export function AIProviderSettings({
|
||||||
aiEnabled,
|
aiEnabled,
|
||||||
onAiEnabledChange
|
aiProvider,
|
||||||
|
claudeApiKey,
|
||||||
|
openaiApiKey,
|
||||||
|
geminiApiKey,
|
||||||
|
showApiKeys,
|
||||||
|
onAiEnabledChange,
|
||||||
|
onProviderChange,
|
||||||
|
onClaudeApiKeyChange,
|
||||||
|
onOpenaiApiKeyChange,
|
||||||
|
onGeminiApiKeyChange,
|
||||||
|
onToggleApiKeyVisibility,
|
||||||
|
maskApiKey
|
||||||
}: AIProviderSettingsProps) {
|
}: 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 (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Brain className="w-5 h-5 text-re-green" />
|
<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>
|
</div>
|
||||||
<CardDescription className="text-sm">
|
<CardDescription className="text-sm">
|
||||||
Configure AI features. Model and region are configured via environment variables.
|
Select your AI provider and configure API keys
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@ -36,7 +96,145 @@ export function AIProviderSettings({
|
|||||||
onCheckedChange={onAiEnabledChange}
|
onCheckedChange={onAiEnabledChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -32,6 +32,7 @@ export function AnalyticsConfig() {
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// TODO: Implement API call to save configuration
|
// TODO: Implement API call to save configuration
|
||||||
|
console.log('Saving analytics configuration:', config);
|
||||||
toast.success('Analytics configuration saved successfully');
|
toast.success('Analytics configuration saved successfully');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -153,6 +153,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
|||||||
|
|
||||||
const renderConfigInput = (config: AdminConfiguration) => {
|
const renderConfigInput = (config: AdminConfiguration) => {
|
||||||
const currentValue = getCurrentValue(config);
|
const currentValue = getCurrentValue(config);
|
||||||
|
const isChanged = hasChanges(config);
|
||||||
const isSaving = saving === config.configKey;
|
const isSaving = saving === config.configKey;
|
||||||
|
|
||||||
if (!config.isEditable) {
|
if (!config.isEditable) {
|
||||||
@ -202,11 +203,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
|||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={1}
|
step={1}
|
||||||
onValueChange={([value]) => {
|
onValueChange={([value]) => handleValueChange(config.configKey, value.toString())}
|
||||||
if (value !== undefined) {
|
|
||||||
handleValueChange(config.configKey, value.toString());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -258,28 +255,34 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter out dashboard layout category and specific config keys
|
const getCategoryColor = (category: string) => {
|
||||||
const excludedCategories = ['DASHBOARD_LAYOUT'];
|
switch (category) {
|
||||||
const excludedConfigKeys = ['ALLOW_EXTERNAL_SHARING', 'NOTIFICATION_BATCH_DELAY_MS', 'AI_REMARK_MAX_CHARACTERS'];
|
case 'TAT_SETTINGS':
|
||||||
const filteredConfigurations = configurations.filter(
|
return 'bg-blue-100 text-blue-600';
|
||||||
config => !excludedCategories.includes(config.configCategory) &&
|
case 'DOCUMENT_POLICY':
|
||||||
!excludedConfigKeys.includes(config.configKey)
|
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]) {
|
if (!acc[config.configCategory]) {
|
||||||
acc[config.configCategory] = [];
|
acc[config.configCategory] = [];
|
||||||
}
|
}
|
||||||
acc[config.configCategory]!.push(config);
|
acc[config.configCategory].push(config);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, AdminConfiguration[]>);
|
}, {} as Record<string, AdminConfiguration[]>);
|
||||||
|
|
||||||
// Sort configs within each category by sortOrder
|
// Sort configs within each category by sortOrder
|
||||||
Object.keys(groupedConfigs).forEach(category => {
|
Object.keys(groupedConfigs).forEach(category => {
|
||||||
const categoryConfigs = groupedConfigs[category];
|
groupedConfigs[category].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
if (categoryConfigs) {
|
|
||||||
categoryConfigs.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -290,7 +293,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredConfigurations.length === 0) {
|
if (configurations.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-lg border-0 rounded-md">
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
<CardContent className="p-12 text-center">
|
<CardContent className="p-12 text-center">
|
||||||
@ -359,23 +362,21 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
|||||||
<Card className="shadow-lg border-0 rounded-md">
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
<CardHeader className="pb-4 border-b border-slate-100">
|
<CardHeader className="pb-4 border-b border-slate-100">
|
||||||
<div className="flex items-center gap-3">
|
<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 rounded-md shadow-sm ${getCategoryColor(category)}`}>
|
||||||
<div className="text-white">
|
|
||||||
{getCategoryIcon(category)}
|
{getCategoryIcon(category)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold text-slate-900">
|
<CardTitle className="text-lg font-semibold text-slate-900">
|
||||||
{category.replace(/_/g, ' ')}
|
{category.replace(/_/g, ' ')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm">
|
<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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<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 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 items-start justify-between gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -413,7 +414,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSave(config)}
|
onClick={() => handleSave(config)}
|
||||||
disabled={!hasChanges(config) || saving === config.configKey}
|
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 ? (
|
{saving === config.configKey ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export function DashboardConfig() {
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// TODO: Implement API call to save dashboard configuration
|
// TODO: Implement API call to save dashboard configuration
|
||||||
|
console.log('Saving dashboard configuration:', config);
|
||||||
toast.success('Dashboard layout saved successfully');
|
toast.success('Dashboard layout saved successfully');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -91,7 +92,7 @@ export function DashboardConfig() {
|
|||||||
<RoleDashboardSection
|
<RoleDashboardSection
|
||||||
key={role}
|
key={role}
|
||||||
role={role}
|
role={role}
|
||||||
kpis={config[role] || {}}
|
kpis={config[role]}
|
||||||
onKPIToggle={(kpi, checked) => handleKPIToggle(role, kpi, checked)}
|
onKPIToggle={(kpi, checked) => handleKPIToggle(role, kpi, checked)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -93,7 +93,7 @@ export function DocumentConfig() {
|
|||||||
<Card className="shadow-lg border-0 rounded-md">
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<FileText className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Upload
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getAllHolidays, createHoliday, updateHoliday, deleteHoliday, Holiday } from '@/services/adminApi';
|
import { getAllHolidays, createHoliday, updateHoliday, deleteHoliday, Holiday } from '@/services/adminApi';
|
||||||
import { formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateShort } from '@/utils/dateFormatter';
|
||||||
@ -88,17 +89,6 @@ export function HolidayManager() {
|
|||||||
setShowAddDialog(true);
|
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 () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -209,7 +199,7 @@ export function HolidayManager() {
|
|||||||
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
|
<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 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="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" />
|
<Calendar className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -234,7 +224,7 @@ export function HolidayManager() {
|
|||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
<span className="hidden xs:inline">Add Holiday</span>
|
<span className="hidden xs:inline">Add Holiday</span>
|
||||||
@ -277,7 +267,7 @@ export function HolidayManager() {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">{month} {selectedYear}</CardTitle>
|
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">{month} {selectedYear}</CardTitle>
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
<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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 bg-blue-50 rounded-md">
|
<div className="p-2 bg-blue-50 rounded-md">
|
||||||
@ -286,7 +276,7 @@ export function HolidayManager() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-4">
|
<CardContent className="space-y-3 pt-4">
|
||||||
{holidaysByMonth[month]?.map(holiday => (
|
{holidaysByMonth[month].map(holiday => (
|
||||||
<div
|
<div
|
||||||
key={holiday.holidayId}
|
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"
|
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 */}
|
{/* Add/Edit Dialog */}
|
||||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||||
<DialogContent className="sm:max-w-[550px] max-h-[90vh] rounded-lg flex flex-col p-0">
|
<DialogContent className="sm:max-w-[500px] rounded-md">
|
||||||
<DialogHeader className="pb-4 border-b border-slate-100 px-6 pt-6 flex-shrink-0">
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3">
|
<DialogTitle className="text-lg sm:text-xl font-semibold text-slate-900">
|
||||||
<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'}
|
{editingHoliday ? 'Edit Holiday' : 'Add New Holiday'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-slate-600 mt-1">
|
<DialogDescription className="text-sm text-slate-600">
|
||||||
{editingHoliday ? 'Update holiday information' : 'Add a new holiday to the calendar for TAT calculations'}
|
{editingHoliday ? 'Update holiday information' : 'Add a new holiday to the calendar'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-5 py-6 px-6 overflow-y-auto flex-1 min-h-0">
|
<div className="space-y-4 py-4">
|
||||||
{/* Date Field */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="date" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
|
<Label htmlFor="date" className="text-sm font-medium text-slate-900">Date *</Label>
|
||||||
Date <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="date"
|
id="date"
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.holidayDate}
|
value={formData.holidayDate}
|
||||||
onChange={(e) => setFormData({ ...formData, holidayDate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, holidayDate: e.target.value })}
|
||||||
min={getMinDate()}
|
className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm"
|
||||||
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">
|
|
||||||
{editingHoliday ? 'Select the holiday date' : 'Select the holiday date (minimum: tomorrow)'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Holiday Name Field */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
|
<Label htmlFor="name" className="text-sm font-medium text-slate-900">Holiday Name *</Label>
|
||||||
Holiday Name <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
placeholder="e.g., Diwali, Republic Day, Christmas"
|
placeholder="e.g., Diwali, Republic Day"
|
||||||
value={formData.holidayName}
|
value={formData.holidayName}
|
||||||
onChange={(e) => setFormData({ ...formData, holidayName: e.target.value })}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Description Field */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description" className="text-sm font-semibold text-slate-900">
|
<Label htmlFor="description" className="text-sm font-medium text-slate-900">Description</Label>
|
||||||
Description <span className="text-slate-400 font-normal text-xs">(Optional)</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="description"
|
id="description"
|
||||||
placeholder="Add additional details about this holiday..."
|
placeholder="Optional description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Holiday Type Field */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type" className="text-sm font-semibold text-slate-900">
|
<Label htmlFor="type" className="text-sm font-medium text-slate-900">Holiday Type</Label>
|
||||||
Holiday Type
|
|
||||||
</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={formData.holidayType}
|
value={formData.holidayType}
|
||||||
onValueChange={(value: Holiday['holidayType']) =>
|
onValueChange={(value: Holiday['holidayType']) =>
|
||||||
setFormData({ ...formData, holidayType: value })
|
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 />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg">
|
<SelectContent className="rounded-md">
|
||||||
<SelectItem value="NATIONAL" className="p-3">
|
<SelectItem value="NATIONAL">National</SelectItem>
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem value="REGIONAL">Regional</SelectItem>
|
||||||
<div className="w-2 h-2 rounded-full bg-red-500"></div>
|
<SelectItem value="ORGANIZATIONAL">Organizational</SelectItem>
|
||||||
<span>National</span>
|
<SelectItem value="OPTIONAL">Optional</SelectItem>
|
||||||
</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-slate-500">Select the category of this holiday</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recurring Checkbox */}
|
<div className="flex items-center gap-2 p-3 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 transition-colors">
|
||||||
<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 })}>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="recurring"
|
id="recurring"
|
||||||
checked={formData.isRecurring}
|
checked={formData.isRecurring}
|
||||||
onChange={(e) => setFormData({ ...formData, isRecurring: e.target.checked })}
|
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-normal cursor-pointer text-sm text-slate-700">
|
||||||
<Label htmlFor="recurring" className="font-semibold cursor-pointer text-sm text-slate-900 block mb-1">
|
This holiday recurs annually
|
||||||
Recurring Holiday
|
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-slate-600">
|
|
||||||
This holiday will automatically repeat every year on the same date
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowAddDialog(false)}
|
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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!formData.holidayDate || !formData.holidayName}
|
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 shadow-sm"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<Calendar className="w-4 h-4 mr-2" />
|
{editingHoliday ? 'Update' : 'Add'} Holiday
|
||||||
{editingHoliday ? 'Update Holiday' : 'Add Holiday'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export function NotificationConfig() {
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// TODO: Implement API call to save notification configuration
|
// TODO: Implement API call to save notification configuration
|
||||||
|
console.log('Saving notification configuration:', config);
|
||||||
toast.success('Notification configuration saved successfully');
|
toast.success('Notification configuration saved successfully');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export function SharingConfig() {
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// TODO: Implement API call to save sharing configuration
|
// TODO: Implement API call to save sharing configuration
|
||||||
|
console.log('Saving sharing configuration:', config);
|
||||||
toast.success('Sharing policy saved successfully');
|
toast.success('Sharing policy saved successfully');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -42,11 +42,7 @@ export function EscalationSettings({
|
|||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
onValueChange={([value]) => {
|
onValueChange={([value]) => onReminderThreshold1Change(value)}
|
||||||
if (value !== undefined) {
|
|
||||||
onReminderThreshold1Change(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
@ -68,11 +64,7 @@ export function EscalationSettings({
|
|||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
onValueChange={([value]) => {
|
onValueChange={([value]) => onReminderThreshold2Change(value)}
|
||||||
if (value !== undefined) {
|
|
||||||
onReminderThreshold2Change(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
|||||||
@ -114,7 +114,7 @@ export function TATConfig() {
|
|||||||
<Card className="shadow-lg border-0 rounded-md">
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Clock className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -14,11 +15,15 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Users,
|
Users,
|
||||||
Shield,
|
Shield,
|
||||||
|
UserCog,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Crown,
|
Crown,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Power
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { userApi } from '@/services/userApi';
|
import { userApi } from '@/services/userApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -89,17 +94,14 @@ export function UserManagement() {
|
|||||||
// Search users from Okta
|
// Search users from Okta
|
||||||
const searchUsers = useCallback(
|
const searchUsers = useCallback(
|
||||||
debounce(async (query: string) => {
|
debounce(async (query: string) => {
|
||||||
// Only trigger search when using @ sign
|
if (!query || query.length < 2) {
|
||||||
if (!query || !query.startsWith('@') || query.length < 2) {
|
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setSearching(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const term = query.slice(1); // Remove @ prefix
|
const response = await userApi.searchUsers(query, 20);
|
||||||
const response = await userApi.searchUsers(term, 20);
|
|
||||||
const users = response.data?.data || [];
|
const users = response.data?.data || [];
|
||||||
setSearchResults(users);
|
setSearchResults(users);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -353,6 +355,27 @@ export function UserManagement() {
|
|||||||
};
|
};
|
||||||
}, [searchResults]);
|
}, [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
|
// Calculate stats for UserStatsCards
|
||||||
const stats = {
|
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" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type @ to search users..."
|
placeholder="Type name or email address..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
className="pl-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
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>
|
||||||
</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 */}
|
{/* Search Results Dropdown */}
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -47,20 +46,7 @@ interface OktaUser {
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
phone?: string;
|
|
||||||
mobilePhone?: string;
|
|
||||||
designation?: string;
|
designation?: string;
|
||||||
jobTitle?: string;
|
|
||||||
manager?: string;
|
|
||||||
employeeId?: string;
|
|
||||||
employeeNumber?: string;
|
|
||||||
secondEmail?: string;
|
|
||||||
location?: {
|
|
||||||
state?: string;
|
|
||||||
city?: string;
|
|
||||||
country?: string;
|
|
||||||
office?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserWithRole {
|
interface UserWithRole {
|
||||||
@ -100,21 +86,21 @@ export function UserRoleManager() {
|
|||||||
// Search users from Okta
|
// Search users from Okta
|
||||||
const searchUsers = useCallback(
|
const searchUsers = useCallback(
|
||||||
debounce(async (query: string) => {
|
debounce(async (query: string) => {
|
||||||
// Only trigger search when using @ sign
|
if (!query || query.length < 2) {
|
||||||
if (!query || !query.startsWith('@') || query.length < 2) {
|
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setSearching(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const term = query.slice(1); // Remove @ prefix
|
const response = await userApi.searchUsers(query, 20);
|
||||||
const response = await userApi.searchUsers(term, 20);
|
console.log('Search response:', response);
|
||||||
|
console.log('Response.data:', response.data);
|
||||||
|
|
||||||
// Backend returns { success: true, data: [...users], message, timestamp }
|
// Backend returns { success: true, data: [...users], message, timestamp }
|
||||||
// Axios response is in response.data, actual user array is in response.data.data
|
// Axios response is in response.data, actual user array is in response.data.data
|
||||||
const users = response.data?.data || [];
|
const users = response.data?.data || [];
|
||||||
|
console.log('Parsed users:', users);
|
||||||
|
|
||||||
setSearchResults(users);
|
setSearchResults(users);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -138,41 +124,10 @@ export function UserRoleManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Select user from search results
|
// Select user from search results
|
||||||
const handleSelectUser = async (user: OktaUser) => {
|
const handleSelectUser = (user: OktaUser) => {
|
||||||
setSelectedUser(user);
|
setSelectedUser(user);
|
||||||
setSearchQuery(user.email);
|
setSearchQuery(user.email);
|
||||||
setSearchResults([]);
|
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
|
// Assign role to user
|
||||||
@ -187,7 +142,6 @@ export function UserRoleManager() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Call backend to assign role (will create user if doesn't exist)
|
// 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);
|
await userApi.assignRole(selectedUser.email, selectedRole);
|
||||||
|
|
||||||
setMessage({
|
setMessage({
|
||||||
@ -220,11 +174,17 @@ export function UserRoleManager() {
|
|||||||
try {
|
try {
|
||||||
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
||||||
|
|
||||||
|
console.log('Users response:', response);
|
||||||
|
|
||||||
// Backend returns { success: true, data: { users: [...], pagination: {...}, summary: {...} } }
|
// Backend returns { success: true, data: { users: [...], pagination: {...}, summary: {...} } }
|
||||||
const usersData = response.data?.data?.users || [];
|
const usersData = response.data?.data?.users || [];
|
||||||
const paginationData = response.data?.data?.pagination;
|
const paginationData = response.data?.data?.pagination;
|
||||||
const summaryData = response.data?.data?.summary;
|
const summaryData = response.data?.data?.summary;
|
||||||
|
|
||||||
|
console.log('Parsed users:', usersData);
|
||||||
|
console.log('Pagination:', paginationData);
|
||||||
|
console.log('Summary:', summaryData);
|
||||||
|
|
||||||
setUsers(usersData);
|
setUsers(usersData);
|
||||||
|
|
||||||
if (paginationData) {
|
if (paginationData) {
|
||||||
@ -252,9 +212,11 @@ export function UserRoleManager() {
|
|||||||
const fetchRoleStatistics = async () => {
|
const fetchRoleStatistics = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await userApi.getRoleStatistics();
|
const response = await userApi.getRoleStatistics();
|
||||||
|
console.log('Role statistics response:', response);
|
||||||
|
|
||||||
// Handle different response formats
|
// Handle different response formats
|
||||||
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
||||||
|
console.log('Statistics data:', statsData);
|
||||||
|
|
||||||
setRoleStats({
|
setRoleStats({
|
||||||
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
|
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
|
||||||
@ -320,41 +282,27 @@ export function UserRoleManager() {
|
|||||||
const getRoleBadgeColor = (role: string) => {
|
const getRoleBadgeColor = (role: string) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'ADMIN':
|
case 'ADMIN':
|
||||||
return 'bg-yellow-400 text-slate-800';
|
return 'bg-yellow-400 text-slate-900';
|
||||||
case 'MANAGEMENT':
|
case 'MANAGEMENT':
|
||||||
return 'bg-blue-400 text-slate-800';
|
return 'bg-blue-400 text-slate-900';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-400 text-slate-800';
|
return 'bg-gray-400 text-white';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoleIcon = (role: string) => {
|
const getRoleIcon = (role: string) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'ADMIN':
|
case 'ADMIN':
|
||||||
return <Crown className="w-5 h-5 text-slate-800" />;
|
return <Crown className="w-5 h-5" />;
|
||||||
case 'MANAGEMENT':
|
case 'MANAGEMENT':
|
||||||
return <Users className="w-5 h-5 text-slate-800" />;
|
return <Users className="w-5 h-5" />;
|
||||||
default:
|
default:
|
||||||
return <UserIcon className="w-5 h-5 text-slate-800" />;
|
return <UserIcon className="w-5 h-5" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-lg border-0 rounded-md">
|
<div className="space-y-6">
|
||||||
<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 */}
|
{/* Statistics Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||||
<Card
|
<Card
|
||||||
@ -374,7 +322,7 @@ export function UserRoleManager() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-xl shadow-md">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -397,7 +345,7 @@ export function UserRoleManager() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl shadow-md">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -420,24 +368,29 @@ export function UserRoleManager() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-gradient-to-br from-gray-400 to-gray-500 rounded-xl shadow-md">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Assign Role Section */}
|
{/* Assign Role Section */}
|
||||||
<div className="space-y-5">
|
<Card className="shadow-lg border">
|
||||||
<div>
|
<CardHeader className="border-b pb-4">
|
||||||
<h3 className="text-base font-semibold text-gray-900 mb-1">Assign User Role</h3>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-gray-600">
|
<div className="p-3 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg shadow-md">
|
||||||
Search for a user in Okta and assign them a role
|
<UserCog className="w-5 h-5 text-white" />
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-5">
|
<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 */}
|
{/* Search Input */}
|
||||||
<div className="space-y-2" ref={searchContainerRef}>
|
<div className="space-y-2" ref={searchContainerRef}>
|
||||||
<label className="text-sm font-medium text-gray-700">Search User</label>
|
<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" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type @ to search users..."
|
placeholder="Type name or email address..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleSearchChange}
|
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"
|
data-testid="user-search-input"
|
||||||
/>
|
/>
|
||||||
{searching && (
|
{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>
|
</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 */}
|
{/* Search Results Dropdown */}
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
@ -465,18 +418,18 @@ export function UserRoleManager() {
|
|||||||
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
|
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-3">
|
||||||
{searchResults.map((user) => (
|
{searchResults.map((user) => (
|
||||||
<button
|
<button
|
||||||
key={user.userId}
|
key={user.userId}
|
||||||
onClick={() => handleSelectUser(user)}
|
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}`}
|
data-testid={`user-result-${user.email}`}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-gray-900">{user.displayName || user.email}</p>
|
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
|
||||||
<p className="text-xs text-gray-600">{user.email}</p>
|
<p className="text-sm text-gray-600">{user.email}</p>
|
||||||
{user.department && (
|
{user.department && (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -489,19 +442,19 @@ export function UserRoleManager() {
|
|||||||
|
|
||||||
{/* Selected User */}
|
{/* Selected User */}
|
||||||
{selectedUser && (
|
{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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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()}
|
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-900">
|
<p className="font-semibold text-gray-900">
|
||||||
{selectedUser.displayName || selectedUser.email}
|
{selectedUser.displayName || selectedUser.email}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-600">{selectedUser.email}</p>
|
<p className="text-sm text-gray-600">{selectedUser.email}</p>
|
||||||
{selectedUser.department && (
|
{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}` : ''}
|
{selectedUser.department}{selectedUser.designation ? ` • ${selectedUser.designation}` : ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -514,7 +467,7 @@ export function UserRoleManager() {
|
|||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
}}
|
}}
|
||||||
className="hover:bg-slate-200"
|
className="hover:bg-purple-100"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
@ -527,25 +480,25 @@ export function UserRoleManager() {
|
|||||||
<label className="text-sm font-medium text-gray-700">Select Role</label>
|
<label className="text-sm font-medium text-gray-700">Select Role</label>
|
||||||
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
|
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
|
||||||
<SelectTrigger
|
<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"
|
data-testid="role-select"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Select role" />
|
<SelectValue placeholder="Select role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="rounded-lg">
|
||||||
<SelectItem value="USER">
|
<SelectItem value="USER" className="p-3 rounded-lg my-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserIcon className="w-4 h-4 text-gray-600" />
|
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||||
<span>User - Regular access</span>
|
<span>User - Regular access</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="MANAGEMENT">
|
<SelectItem value="MANAGEMENT" className="p-3 rounded-lg my-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="w-4 h-4 text-blue-600" />
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
<span>Management - Read all data</span>
|
<span>Management - Read all data</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="ADMIN">
|
<SelectItem value="ADMIN" className="p-3 rounded-lg my-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Crown className="w-4 h-4 text-yellow-600" />
|
<Crown className="w-4 h-4 text-yellow-600" />
|
||||||
<span>Administrator - Full access</span>
|
<span>Administrator - Full access</span>
|
||||||
@ -559,7 +512,7 @@ export function UserRoleManager() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleAssignRole}
|
onClick={handleAssignRole}
|
||||||
disabled={!selectedUser || updating}
|
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"
|
data-testid="assign-role-button"
|
||||||
>
|
>
|
||||||
{updating ? (
|
{updating ? (
|
||||||
@ -594,19 +547,24 @@ export function UserRoleManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Users List with Filter and Pagination */}
|
{/* Users List with Filter and Pagination */}
|
||||||
<div ref={userListRef}>
|
<div ref={userListRef}>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
<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>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-gray-900 mb-1">User Management</h3>
|
<CardTitle className="text-lg font-semibold">User Management</CardTitle>
|
||||||
<p className="text-sm text-gray-600">
|
<CardDescription className="text-sm">
|
||||||
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
|
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
|
||||||
</p>
|
</CardDescription>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Select value={roleFilter} onValueChange={handleFilterChange}>
|
<Select value={roleFilter} onValueChange={handleFilterChange}>
|
||||||
@ -648,7 +606,8 @@ export function UserRoleManager() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2">
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
{loadingUsers ? (
|
{loadingUsers ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8">
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
|
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
|
||||||
@ -673,7 +632,7 @@ export function UserRoleManager() {
|
|||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<div
|
<div
|
||||||
key={user.userId}
|
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}`}
|
data-testid={`user-${user.email}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
@ -735,7 +694,7 @@ export function UserRoleManager() {
|
|||||||
onClick={() => handlePageChange(pageNum)}
|
onClick={() => handlePageChange(pageNum)}
|
||||||
className={`w-9 h-9 p-0 ${
|
className={`w-9 h-9 p-0 ${
|
||||||
currentPage === pageNum
|
currentPage === pageNum
|
||||||
? 'bg-re-green hover:bg-re-green/90 text-white'
|
? 'bg-purple-500 hover:bg-purple-600'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
data-testid={`page-${pageNum}-button`}
|
data-testid={`page-${pageNum}-button`}
|
||||||
@ -759,10 +718,10 @@ export function UserRoleManager() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
export { ConfigurationManager } from './ConfigurationManager';
|
export { ConfigurationManager } from './ConfigurationManager';
|
||||||
export { HolidayManager } from './HolidayManager';
|
export { HolidayManager } from './HolidayManager';
|
||||||
export { ActivityTypeManager } from './ActivityTypeManager';
|
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CheckCircle } from 'lucide-react';
|
import { CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
type ApprovalModalProps = {
|
type ApprovalModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AlertTriangle, RefreshCw, ArrowLeft } from 'lucide-react';
|
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);
|
console.error('Error Boundary caught an error:', error, errorInfo);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -54,7 +54,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
override render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
// Custom fallback if provided
|
// Custom fallback if provided
|
||||||
if (this.props.fallback) {
|
if (this.props.fallback) {
|
||||||
|
|||||||
@ -54,54 +54,23 @@ export function FilePreview({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const token = localStorage.getItem('accessToken');
|
||||||
const token = isProduction ? null : localStorage.getItem('accessToken');
|
const response = await fetch(fileUrl, {
|
||||||
|
headers: {
|
||||||
// Ensure we have a valid URL - handle relative URLs when served from same origin
|
'Authorization': `Bearer ${token}`
|
||||||
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'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text().catch(() => '');
|
throw new Error('Failed to load file');
|
||||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}. ${errorText}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
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);
|
const url = window.URL.createObjectURL(blob);
|
||||||
setBlobUrl(url);
|
setBlobUrl(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load file for preview:', 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -113,10 +82,9 @@ export function FilePreview({
|
|||||||
return () => {
|
return () => {
|
||||||
if (blobUrl) {
|
if (blobUrl) {
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
setBlobUrl(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [open, fileUrl, canPreview, isPDF]);
|
}, [open, fileUrl, canPreview]);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (onDownload && attachmentId) {
|
if (onDownload && attachmentId) {
|
||||||
@ -250,9 +218,6 @@ export function FilePreview({
|
|||||||
minHeight: '70vh',
|
minHeight: '70vh',
|
||||||
height: '100%'
|
height: '100%'
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
|
||||||
setError('Failed to load PDF preview');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
interface LoaderProps {
|
interface LoaderProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|||||||
@ -40,45 +40,20 @@ export function Pagination({
|
|||||||
return pages;
|
return pages;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate display values
|
// Don't show pagination if only 1 page or loading
|
||||||
const startItem = totalRecords > 0 ? ((currentPage - 1) * itemsPerPage) + 1 : 0;
|
if (totalPages <= 1 || loading) {
|
||||||
const endItem = Math.min(currentPage * itemsPerPage, totalRecords);
|
return null;
|
||||||
|
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
|
||||||
|
const endItem = Math.min(currentPage * itemsPerPage, totalRecords);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-md border-gray-200" data-testid={`${testIdPrefix}-container`}>
|
<Card className="shadow-md" data-testid={`${testIdPrefix}-container`}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||||
<div
|
<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`}
|
data-testid={`${testIdPrefix}-info`}
|
||||||
>
|
>
|
||||||
Showing {startItem} to {endItem} of {totalRecords} {itemLabel}
|
Showing {startItem} to {endItem} of {totalRecords} {itemLabel}
|
||||||
@ -121,7 +96,7 @@ export function Pagination({
|
|||||||
variant={pageNum === currentPage ? "default" : "outline"}
|
variant={pageNum === currentPage ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onPageChange(pageNum)}
|
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}`}
|
data-testid={`${testIdPrefix}-page-${pageNum}`}
|
||||||
aria-current={pageNum === currentPage ? 'page' : undefined}
|
aria-current={pageNum === currentPage ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { LucideIcon, Info } from 'lucide-react';
|
import { LucideIcon } from 'lucide-react';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
interface KPICardProps {
|
interface KPICardProps {
|
||||||
@ -12,8 +12,6 @@ interface KPICardProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onJustifyClick?: () => void;
|
|
||||||
showJustifyButton?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KPICard({
|
export function KPICard({
|
||||||
@ -25,66 +23,45 @@ export function KPICard({
|
|||||||
subtitle,
|
subtitle,
|
||||||
children,
|
children,
|
||||||
testId = 'kpi-card',
|
testId = 'kpi-card',
|
||||||
onClick,
|
onClick
|
||||||
onJustifyClick,
|
|
||||||
showJustifyButton = false
|
|
||||||
}: KPICardProps) {
|
}: KPICardProps) {
|
||||||
const handleJustifyClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation(); // Prevent card onClick from firing
|
|
||||||
onJustifyClick?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<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}
|
onClick={onClick}
|
||||||
data-testid={testId}
|
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
|
<CardTitle
|
||||||
className="text-sm font-medium text-muted-foreground"
|
className="text-sm font-medium text-muted-foreground"
|
||||||
data-testid={`${testId}-title`}
|
data-testid={`${testId}-title`}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className={`p-2 sm:p-3 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
|
||||||
{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
|
<Icon
|
||||||
className={`h-4 w-4 sm:h-4 sm:w-4 ${iconColor}`}
|
className={`h-4 w-4 sm:h-5 sm:w-5 ${iconColor}`}
|
||||||
data-testid={`${testId}-icon`}
|
data-testid={`${testId}-icon`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col flex-1 py-3">
|
<CardContent>
|
||||||
<div
|
<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`}
|
data-testid={`${testId}-value`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<div
|
<div
|
||||||
className="text-xs text-muted-foreground mb-2"
|
className="text-xs text-muted-foreground mb-3"
|
||||||
data-testid={`${testId}-subtitle`}
|
data-testid={`${testId}-subtitle`}
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children && (
|
{children && (
|
||||||
<div className="flex-1 flex flex-col" data-testid={`${testId}-children`}>
|
<div data-testid={`${testId}-children`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ interface StatCardProps {
|
|||||||
textColor: string;
|
textColor: string;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
onClick?: (e: React.MouseEvent) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatCard({
|
export function StatCard({
|
||||||
@ -16,17 +15,15 @@ export function StatCard({
|
|||||||
bgColor,
|
bgColor,
|
||||||
textColor,
|
textColor,
|
||||||
testId = 'stat-card',
|
testId = 'stat-card',
|
||||||
children,
|
children
|
||||||
onClick
|
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
data-testid={testId}
|
||||||
onClick={onClick}
|
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className="text-xs text-gray-600 mb-1 leading-tight"
|
className="text-xs text-gray-600 mb-1"
|
||||||
data-testid={`${testId}-label`}
|
data-testid={`${testId}-label`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ interface StatsCardProps {
|
|||||||
textColor: string;
|
textColor: string;
|
||||||
valueColor: string;
|
valueColor: string;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
onClick?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsCard({
|
export function StatsCard({
|
||||||
@ -21,14 +20,12 @@ export function StatsCard({
|
|||||||
gradient,
|
gradient,
|
||||||
textColor,
|
textColor,
|
||||||
valueColor,
|
valueColor,
|
||||||
testId = 'stats-card',
|
testId = 'stats-card'
|
||||||
onClick
|
|
||||||
}: StatsCardProps) {
|
}: StatsCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<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}
|
data-testid={testId}
|
||||||
onClick={onClick}
|
|
||||||
>
|
>
|
||||||
<CardContent className="p-3 sm:p-4">
|
<CardContent className="p-3 sm:p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Bell, Settings, User, Plus, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List, Share2 } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||||
@ -15,11 +16,10 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
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 notificationApi, { Notification } from '@/services/notificationApi';
|
||||||
import { getSocket, joinUserRoom } from '@/utils/socket';
|
import { getSocket, joinUserRoom } from '@/utils/socket';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { TokenManager } from '@/utils/tokenManager';
|
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -37,17 +37,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Check if user is a Dealer
|
|
||||||
const isDealer = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const userData = TokenManager.getUserData();
|
|
||||||
return userData?.jobTitle === 'Dealer';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[PageLayout] Error checking dealer status:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get user initials for avatar
|
// Get user initials for avatar
|
||||||
const getUserInitials = () => {
|
const getUserInitials = () => {
|
||||||
try {
|
try {
|
||||||
@ -68,27 +57,13 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = [
|
||||||
const items = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
||||||
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
|
{ id: 'my-requests', label: 'My Requests', icon: User },
|
||||||
{ 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: 'open-requests', label: 'Open Requests', icon: FileText },
|
||||||
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
||||||
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
|
{ id: 'admin', label: 'Admin', icon: Shield },
|
||||||
);
|
];
|
||||||
|
|
||||||
return items;
|
|
||||||
}, [isDealer]);
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setSidebarOpen(!sidebarOpen);
|
setSidebarOpen(!sidebarOpen);
|
||||||
@ -122,6 +97,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
|
|
||||||
// Navigate to request detail page
|
// Navigate to request detail page
|
||||||
onNavigate(navigationUrl);
|
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 || [];
|
const notifs = result.data?.notifications || [];
|
||||||
setNotifications(notifs);
|
setNotifications(notifs);
|
||||||
setUnreadCount(result.data?.unreadCount || 0);
|
setUnreadCount(result.data?.unreadCount || 0);
|
||||||
|
|
||||||
|
console.log('[PageLayout] Loaded', notifs.length, 'recent notifications,', result.data?.unreadCount, 'unread');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PageLayout] Failed to fetch notifications:', error);
|
console.error('[PageLayout] Failed to fetch notifications:', error);
|
||||||
}
|
}
|
||||||
@ -165,7 +143,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
|
|
||||||
// Setup socket for real-time notifications
|
// 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) {
|
if (socket) {
|
||||||
// Join user's personal notification room
|
// Join user's personal notification room
|
||||||
@ -173,6 +152,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
|
|
||||||
// Listen for new notifications
|
// Listen for new notifications
|
||||||
const handleNewNotification = (data: { notification: Notification }) => {
|
const handleNewNotification = (data: { notification: Notification }) => {
|
||||||
|
console.log('[PageLayout] 🔔 New notification received:', data);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
|
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
|
||||||
@ -238,32 +218,32 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
`}>
|
`}>
|
||||||
<div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}>
|
<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="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
|
<img
|
||||||
src={ReLogo}
|
src={royalEnfieldLogo}
|
||||||
alt="Royal Enfield Logo"
|
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>
|
</div>
|
||||||
<div className="p-3 flex-1 overflow-y-auto">
|
<div className="p-3 flex-1 overflow-y-auto">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{menuItems.filter(item => !item.adminOnly || (user as any)?.role === 'ADMIN').map((item) => (
|
{menuItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.id === 'admin/templates') {
|
|
||||||
onNavigate?.('admin/templates');
|
|
||||||
} else {
|
|
||||||
onNavigate?.(item.id);
|
onNavigate?.(item.id);
|
||||||
}
|
|
||||||
// Close sidebar on mobile after navigation
|
// Close sidebar on mobile after navigation
|
||||||
if (window.innerWidth < 768) {
|
if (window.innerWidth < 768) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id
|
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'
|
? 'bg-re-green text-white font-medium'
|
||||||
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
|
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
@ -275,7 +255,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Action in Sidebar - Right below menu items */}
|
{/* Quick Action in Sidebar - Right below menu items */}
|
||||||
{/* {!isDealer && ( */}
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={onNewRequest}
|
onClick={onNewRequest}
|
||||||
@ -286,7 +265,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
Raise New Request
|
Raise New Request
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* )} */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -304,18 +282,16 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
>
|
>
|
||||||
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
|
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
|
||||||
</Button>
|
</Button>
|
||||||
{/* Search bar commented out */}
|
<div className="relative max-w-md flex-1">
|
||||||
{/* <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" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
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"
|
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>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
{!isDealer && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onNewRequest}
|
onClick={onNewRequest}
|
||||||
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
|
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
|
||||||
@ -324,7 +300,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
New Request
|
New Request
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@ -365,7 +340,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
{notifications.map((notif) => (
|
{notifications.map((notif) => (
|
||||||
<div
|
<div
|
||||||
key={notif.notificationId}
|
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)}
|
onClick={() => handleNotificationClick(notif)}
|
||||||
>
|
>
|
||||||
@ -461,10 +437,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
console.log('🔴 Logout button clicked in PageLayout');
|
||||||
|
console.log('🔴 onLogout function exists?', !!onLogout);
|
||||||
setShowLogoutDialog(false);
|
setShowLogoutDialog(false);
|
||||||
if (onLogout) {
|
if (onLogout) {
|
||||||
|
console.log('🔴 Calling onLogout function...');
|
||||||
try {
|
try {
|
||||||
await onLogout();
|
await onLogout();
|
||||||
|
console.log('🔴 onLogout completed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('🔴 Error calling onLogout:', error);
|
console.error('🔴 Error calling onLogout:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,11 @@ interface AddUserModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
type: 'approver' | 'spectator';
|
type: 'approver' | 'spectator';
|
||||||
|
requestId: string;
|
||||||
requestTitle: 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 [email, setEmail] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Textarea } from '../ui/textarea';
|
import { Textarea } from '../ui/textarea';
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
|||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Avatar, AvatarFallback } from '../ui/avatar';
|
import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||||
import { Progress } from '../ui/progress';
|
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 { Switch } from '../ui/switch';
|
||||||
import { Calendar } from '../ui/calendar';
|
import { Calendar } from '../ui/calendar';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||||
@ -18,6 +18,8 @@ import {
|
|||||||
Calendar as CalendarIcon,
|
Calendar as CalendarIcon,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
Check,
|
Check,
|
||||||
Users
|
Users
|
||||||
@ -183,7 +185,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="w-full justify-start text-left">
|
<Button variant="outline" className="w-full justify-start text-left">
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{formData.slaEndDate ? format(formData.slaEndDate, 'd MMM yyyy') : 'Pick a date'}
|
{formData.slaEndDate ? format(formData.slaEndDate, 'PPP') : 'Pick a date'}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0">
|
<PopoverContent className="w-auto p-0">
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,23 +1,24 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import {
|
import {
|
||||||
|
FileText,
|
||||||
Receipt,
|
Receipt,
|
||||||
Package,
|
Package,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowLeft,
|
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Target,
|
Target,
|
||||||
|
X,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Check,
|
Check
|
||||||
AlertCircle
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { TokenManager } from '../../utils/tokenManager';
|
|
||||||
|
|
||||||
interface TemplateSelectionModalProps {
|
interface TemplateSelectionModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -41,8 +42,7 @@ const AVAILABLE_TEMPLATES = [
|
|||||||
'Document verification',
|
'Document verification',
|
||||||
'E-invoice generation',
|
'E-invoice generation',
|
||||||
'Credit note issuance'
|
'Credit note issuance'
|
||||||
],
|
]
|
||||||
disabled: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vendor-payment',
|
id: 'vendor-payment',
|
||||||
@ -58,32 +58,14 @@ const AVAILABLE_TEMPLATES = [
|
|||||||
'Invoice verification',
|
'Invoice verification',
|
||||||
'Multi-level approvals',
|
'Multi-level approvals',
|
||||||
'Payment scheduling'
|
'Payment scheduling'
|
||||||
],
|
]
|
||||||
disabled: true,
|
|
||||||
comingSoon: true
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
|
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||||
const [isDealer, setIsDealer] = useState(false);
|
|
||||||
|
|
||||||
// Check if user is a Dealer
|
|
||||||
useEffect(() => {
|
|
||||||
const userData = TokenManager.getUserData();
|
|
||||||
setIsDealer(userData?.jobTitle === 'Dealer');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelect = (templateId: string) => {
|
const handleSelect = (templateId: string) => {
|
||||||
// Don't allow selection if user is a dealer
|
|
||||||
if (isDealer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Don't allow selection if template is disabled
|
|
||||||
const template = AVAILABLE_TEMPLATES.find(t => t.id === templateId);
|
|
||||||
if (template?.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedTemplate(templateId);
|
setSelectedTemplate(templateId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -105,13 +87,12 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
|
Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
{/* Back arrow button - Top left */}
|
{/* Custom Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="!flex absolute top-6 left-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 items-center justify-center transition-all hover:scale-110"
|
className="absolute top-6 right-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 flex items-center justify-center transition-all hover:scale-110"
|
||||||
aria-label="Go back"
|
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
<X className="w-5 h-5 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Full Screen Content Container */}
|
{/* Full Screen Content Container */}
|
||||||
@ -139,7 +120,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
{AVAILABLE_TEMPLATES.map((template, index) => {
|
{AVAILABLE_TEMPLATES.map((template, index) => {
|
||||||
const Icon = template.icon;
|
const Icon = template.icon;
|
||||||
const isSelected = selectedTemplate === template.id;
|
const isSelected = selectedTemplate === template.id;
|
||||||
const isDisabled = isDealer || template.disabled;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -147,16 +127,14 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
whileHover={isDisabled ? {} : { scale: 1.03 }}
|
whileHover={{ scale: 1.03 }}
|
||||||
whileTap={isDisabled ? {} : { scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`h-full transition-all duration-300 border-2 ${
|
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
|
||||||
isDisabled
|
isSelected
|
||||||
? 'opacity-50 cursor-not-allowed border-gray-200'
|
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
||||||
: isSelected
|
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
||||||
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
|
||||||
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleSelect(template.id)}
|
onClick={() => handleSelect(template.id)}
|
||||||
>
|
>
|
||||||
@ -182,22 +160,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
<CardDescription className="text-sm leading-relaxed">
|
<CardDescription className="text-sm leading-relaxed">
|
||||||
{template.description}
|
{template.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
{isDealer && (
|
|
||||||
<div className="mt-3 flex items-start gap-2 p-2 bg-amber-50 border border-amber-200 rounded-lg">
|
|
||||||
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-xs text-amber-800">
|
|
||||||
Not accessible for Dealers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{template.comingSoon && !isDealer && (
|
|
||||||
<div className="mt-3 flex items-start gap-2 p-2 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-xs text-blue-800 font-semibold">
|
|
||||||
Coming Soon
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 space-y-4">
|
<CardContent className="pt-0 space-y-4">
|
||||||
@ -260,12 +222,12 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleContinue}
|
onClick={handleContinue}
|
||||||
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
|
disabled={!selectedTemplate}
|
||||||
size="lg"
|
size="lg"
|
||||||
className={`gap-2 px-8 ${
|
className={`gap-2 px-8 ${
|
||||||
selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
|
selectedTemplate
|
||||||
? 'bg-blue-600 hover:bg-blue-700'
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
: 'bg-gray-400 cursor-not-allowed'
|
: 'bg-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Continue with Template
|
Continue with Template
|
||||||
|
|||||||
@ -38,6 +38,7 @@ interface WorkNoteModalProps {
|
|||||||
|
|
||||||
export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps) {
|
export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps) {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const participants = [
|
const participants = [
|
||||||
@ -138,13 +139,11 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
|
|
||||||
const extractMentions = (text: string): string[] => {
|
const extractMentions = (text: string): string[] => {
|
||||||
const mentionRegex = /@(\w+\s?\w+)/g;
|
const mentionRegex = /@(\w+\s?\w+)/g;
|
||||||
const mentions: string[] = [];
|
const mentions = [];
|
||||||
let match;
|
let match;
|
||||||
while ((match = mentionRegex.exec(text)) !== null) {
|
while ((match = mentionRegex.exec(text)) !== null) {
|
||||||
if (match[1]) {
|
|
||||||
mentions.push(match[1]);
|
mentions.push(match[1]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return mentions;
|
return mentions;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -231,6 +230,23 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
</div>
|
</div>
|
||||||
</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 ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@ -24,18 +24,16 @@ interface AddApproverModalProps {
|
|||||||
requestTitle?: string;
|
requestTitle?: string;
|
||||||
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
|
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
|
||||||
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
|
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
|
||||||
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
|
|
||||||
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddApproverModal({
|
export function AddApproverModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
requestIdDisplay,
|
||||||
|
requestTitle,
|
||||||
existingParticipants = [],
|
existingParticipants = [],
|
||||||
currentLevels = [],
|
currentLevels = []
|
||||||
maxApprovalLevels,
|
|
||||||
onPolicyViolation
|
|
||||||
}: AddApproverModalProps) {
|
}: AddApproverModalProps) {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [tatHours, setTatHours] = useState<number>(24);
|
const [tatHours, setTatHours] = useState<number>(24);
|
||||||
@ -144,36 +142,6 @@ export function AddApproverModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate against maxApprovalLevels policy
|
|
||||||
// Calculate the new total levels after adding this approver
|
|
||||||
// If inserting at a level that already exists, levels shift down, so total stays same
|
|
||||||
// If inserting at a new level (beyond current), total increases
|
|
||||||
const currentMaxLevel = currentLevels.length > 0
|
|
||||||
? Math.max(...currentLevels.map(l => l.levelNumber), 0)
|
|
||||||
: 0;
|
|
||||||
const newTotalLevels = selectedLevel > currentMaxLevel
|
|
||||||
? selectedLevel // New level beyond current max
|
|
||||||
: currentMaxLevel + 1; // Existing level, shifts everything down, adds one more
|
|
||||||
|
|
||||||
if (maxApprovalLevels && newTotalLevels > maxApprovalLevels) {
|
|
||||||
if (onPolicyViolation) {
|
|
||||||
onPolicyViolation([{
|
|
||||||
type: 'Maximum Approval Levels Exceeded',
|
|
||||||
message: `Adding an approver at level ${selectedLevel} would result in ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove an approver or contact your administrator.`,
|
|
||||||
currentValue: newTotalLevels,
|
|
||||||
maxValue: maxApprovalLevels
|
|
||||||
}]);
|
|
||||||
} else {
|
|
||||||
setValidationModal({
|
|
||||||
open: true,
|
|
||||||
type: 'error',
|
|
||||||
email: '',
|
|
||||||
message: `Cannot add approver. This would exceed the maximum allowed approval levels (${maxApprovalLevels}). Current request has ${currentMaxLevel} level(s).`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already a participant
|
// Check if user is already a participant
|
||||||
const existingParticipant = existingParticipants.find(
|
const existingParticipant = existingParticipants.find(
|
||||||
p => (p.email || '').toLowerCase() === emailToAdd
|
p => (p.email || '').toLowerCase() === emailToAdd
|
||||||
@ -244,17 +212,10 @@ export function AddApproverModal({
|
|||||||
displayName: foundUser.displayName,
|
displayName: foundUser.displayName,
|
||||||
firstName: foundUser.firstName,
|
firstName: foundUser.firstName,
|
||||||
lastName: foundUser.lastName,
|
lastName: foundUser.lastName,
|
||||||
department: foundUser.department,
|
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to validate approver:', error);
|
console.error('Failed to validate approver:', error);
|
||||||
setValidationModal({
|
setValidationModal({
|
||||||
@ -374,22 +335,14 @@ export function AddApproverModal({
|
|||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
department: user.department,
|
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setEmail(user.email);
|
setEmail(user.email);
|
||||||
setSelectedUser(user); // Track that user was selected via @ search
|
setSelectedUser(user); // Track that user was selected via @ search
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
|
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to ensure user exists:', error);
|
console.error('Failed to ensure user exists:', error);
|
||||||
setValidationModal({
|
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.
|
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Max Approval Levels Note */}
|
|
||||||
{maxApprovalLevels && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2">
|
|
||||||
<p className="text-xs text-blue-800">
|
|
||||||
ℹ️ Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
|
||||||
{currentLevels.length > 0 && (
|
|
||||||
<span className="ml-2">
|
|
||||||
({Math.max(...currentLevels.map(l => l.levelNumber), 0)}/{maxApprovalLevels})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current Levels Display */}
|
{/* Current Levels Display */}
|
||||||
{currentLevels.length > 0 && (
|
{currentLevels.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
|
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
|
||||||
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
|
||||||
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
|
||||||
|
|
||||||
interface AddSpectatorModalProps {
|
interface AddSpectatorModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -21,6 +19,8 @@ export function AddSpectatorModal({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
requestIdDisplay,
|
||||||
|
requestTitle,
|
||||||
existingParticipants = []
|
existingParticipants = []
|
||||||
}: AddSpectatorModalProps) {
|
}: AddSpectatorModalProps) {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@ -44,57 +44,6 @@ export function AddSpectatorModal({
|
|||||||
message: ''
|
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 handleConfirm = async () => {
|
||||||
const emailToAdd = email.trim().toLowerCase();
|
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 user was NOT selected via @ search, validate against Okta
|
||||||
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
|
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
|
||||||
try {
|
try {
|
||||||
@ -239,17 +139,10 @@ export function AddSpectatorModal({
|
|||||||
displayName: foundUser.displayName,
|
displayName: foundUser.displayName,
|
||||||
firstName: foundUser.firstName,
|
firstName: foundUser.firstName,
|
||||||
lastName: foundUser.lastName,
|
lastName: foundUser.lastName,
|
||||||
department: foundUser.department,
|
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to validate spectator:', error);
|
console.error('Failed to validate spectator:', error);
|
||||||
setValidationModal({
|
setValidationModal({
|
||||||
@ -355,22 +248,14 @@ export function AddSpectatorModal({
|
|||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
department: user.department,
|
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setEmail(user.email);
|
setEmail(user.email);
|
||||||
setSelectedUser(user); // Track that user was selected via @ search
|
setSelectedUser(user); // Track that user was selected via @ search
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
|
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to ensure user exists:', error);
|
console.error('Failed to ensure user exists:', error);
|
||||||
setValidationModal({
|
setValidationModal({
|
||||||
@ -562,19 +447,6 @@ export function AddSpectatorModal({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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 { Button } from '@/components/ui/button';
|
||||||
import { Bell, CheckCircle, XCircle } from 'lucide-react';
|
import { Bell, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
@ -23,11 +23,6 @@ export function NotificationStatusModal({
|
|||||||
<Bell className="w-5 h-5 text-blue-600" />
|
<Bell className="w-5 h-5 text-blue-600" />
|
||||||
Push Notifications
|
Push Notifications
|
||||||
</DialogTitle>
|
</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>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="py-6">
|
<div className="py-6">
|
||||||
@ -52,7 +47,7 @@ export function NotificationStatusModal({
|
|||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Subscription Failed
|
Subscription Failed
|
||||||
</h3>
|
</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.'}
|
{message || 'Unable to enable push notifications. Please check your browser settings and try again.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-left w-full">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-left w-full">
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
|
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
|
||||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
|
||||||
|
|
||||||
export interface SLAData {
|
export interface SLAData {
|
||||||
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
|
status: 'normal' | 'approaching' | 'critical' | 'breached';
|
||||||
percentageUsed?: number;
|
percentageUsed: number;
|
||||||
percent?: number; // Simplified format (alternative to percentageUsed)
|
|
||||||
elapsedText: string;
|
elapsedText: string;
|
||||||
elapsedHours: number;
|
elapsedHours: number;
|
||||||
remainingText: string;
|
remainingText: string;
|
||||||
@ -18,22 +15,16 @@ export interface SLAData {
|
|||||||
interface SLAProgressBarProps {
|
interface SLAProgressBarProps {
|
||||||
sla: SLAData | null;
|
sla: SLAData | null;
|
||||||
requestStatus: string;
|
requestStatus: string;
|
||||||
isPaused?: boolean;
|
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SLAProgressBar({
|
export function SLAProgressBar({
|
||||||
sla,
|
sla,
|
||||||
requestStatus,
|
requestStatus,
|
||||||
isPaused = false,
|
|
||||||
testId = 'sla-progress'
|
testId = 'sla-progress'
|
||||||
}: SLAProgressBarProps) {
|
}: SLAProgressBarProps) {
|
||||||
// Pure presentational component - no business logic
|
|
||||||
// If request is closed/approved/rejected or no SLA data, show status message
|
// If request is closed/approved/rejected or no SLA data, show status message
|
||||||
// Check if SLA has required fields (percentageUsed or at least some data)
|
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
||||||
const hasValidSLA = sla && (sla.percentageUsed !== undefined || sla.elapsedHours !== undefined);
|
|
||||||
|
|
||||||
if (!hasValidSLA || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
|
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
|
||||||
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
|
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
|
||||||
@ -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 (
|
return (
|
||||||
<div data-testid={testId}>
|
<div data-testid={testId}>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isPaused ? (
|
<Clock className="h-4 w-4 text-blue-600" />
|
||||||
<Lock className={`h-4 w-4 ${colors.icon}`} />
|
<span className="text-sm font-semibold text-gray-900">SLA Progress</span>
|
||||||
) : (
|
|
||||||
<Clock className={`h-4 w-4 ${colors.icon}`} />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
|
||||||
{isPaused ? 'SLA Progress (Paused)' : 'SLA Progress'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<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`}
|
data-testid={`${testId}-badge`}
|
||||||
>
|
>
|
||||||
{percentageUsed}% elapsed {isPaused && '(frozen)'}
|
{sla.percentageUsed || 0}% elapsed
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Progress
|
<Progress
|
||||||
value={percentageUsed}
|
value={sla.percentageUsed || 0}
|
||||||
className="h-3 mb-2"
|
className={`h-3 mb-2 ${
|
||||||
indicatorClassName={colors.progress}
|
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`}
|
data-testid={`${testId}-bar`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1">
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
<span className="text-gray-600" data-testid={`${testId}-elapsed`}>
|
<span className="text-gray-600" data-testid={`${testId}-elapsed`}>
|
||||||
{formatHoursMinutes(sla.elapsedHours || 0)} elapsed
|
{sla.elapsedText || `${sla.elapsedHours || 0}h`} elapsed
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${
|
className={`font-semibold ${
|
||||||
normalizedStatus === 'breached' ? colors.text :
|
sla.status === 'breached' ? 'text-red-600' :
|
||||||
normalizedStatus === 'critical' ? colors.text :
|
sla.status === 'critical' ? 'text-orange-600' :
|
||||||
'text-gray-700'
|
'text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`${testId}-remaining`}
|
data-testid={`${testId}-remaining`}
|
||||||
>
|
>
|
||||||
{formatHoursMinutes(sla.remainingHours || 0)} remaining
|
{sla.remainingText || `${sla.remainingHours || 0}h`} remaining
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sla.deadline && (
|
{sla.deadline && (
|
||||||
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
|
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
|
||||||
Due: {formatDateDDMMYYYY(sla.deadline, true)} • {percentageUsed}% elapsed
|
Due: {new Date(sla.deadline).toLocaleString()} • {sla.percentageUsed || 0}% elapsed
|
||||||
</p>
|
</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`}>
|
<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" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
Approaching Deadline
|
Approaching Deadline
|
||||||
</p>
|
</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`}>
|
<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" />
|
<AlertOctagon className="h-3.5 w-3.5" />
|
||||||
URGENT - Deadline Passed
|
URGENT - Deadline Passed
|
||||||
|
|||||||
@ -22,14 +22,14 @@ export function SLATracker({ startDate, deadline, priority, className = '', show
|
|||||||
const getProgressColor = () => {
|
const getProgressColor = () => {
|
||||||
if (slaStatus.progress >= 100) return 'bg-red-500';
|
if (slaStatus.progress >= 100) return 'bg-red-500';
|
||||||
if (slaStatus.progress >= 75) return 'bg-orange-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';
|
return 'bg-green-500';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadgeColor = () => {
|
const getStatusBadgeColor = () => {
|
||||||
if (slaStatus.progress >= 100) return 'bg-red-100 text-red-800 border-red-200';
|
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 >= 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';
|
return 'bg-green-100 text-green-800 border-green-200';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
|
||||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { FilePreview } from '@/components/common/FilePreview';
|
import { FilePreview } from '@/components/common/FilePreview';
|
||||||
import { ActionStatusModal } from '@/components/common/ActionStatusModal';
|
|
||||||
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
|
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
|
||||||
import { AddApproverModal } from '@/components/participant/AddApproverModal';
|
import { AddApproverModal } from '@/components/participant/AddApproverModal';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
@ -73,17 +72,15 @@ interface Participant {
|
|||||||
|
|
||||||
interface WorkNoteChatProps {
|
interface WorkNoteChatProps {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
onBack?: () => void;
|
||||||
messages?: any[]; // optional external messages
|
messages?: any[]; // optional external messages
|
||||||
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
|
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
|
||||||
skipSocketJoin?: boolean; // Set to true when embedded in RequestDetail (to avoid double join)
|
skipSocketJoin?: boolean; // Set to true when embedded in RequestDetail (to avoid double join)
|
||||||
requestTitle?: string; // Optional title for display
|
requestTitle?: string; // Optional title for display
|
||||||
onAttachmentsExtracted?: (attachments: any[]) => void; // Callback to pass attachments to parent
|
onAttachmentsExtracted?: (attachments: any[]) => void; // Callback to pass attachments to parent
|
||||||
isInitiator?: boolean; // Whether current user is the initiator
|
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
|
currentLevels?: any[]; // Current approval levels for add approver modal
|
||||||
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
|
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
|
||||||
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
|
|
||||||
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void; // Callback for policy violations
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All data is now fetched from backend - no hardcoded mock data
|
// All data is now fetched from backend - no hardcoded mock data
|
||||||
@ -109,20 +106,9 @@ const getStatusText = (status: string) => {
|
|||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
// Enhanced mention highlighting - Blue color with extra bold font for high visibility
|
// Enhanced mention highlighting - Blue color with extra bold font for high visibility
|
||||||
// Matches: @username or @FirstName LastName (only one space allowed for first name + last name)
|
// Matches: @test user11 or @Test User11 (any case, stops before next sentence/punctuation)
|
||||||
// Pattern: @word or @word word (stops after second word)
|
|
||||||
return content
|
return content
|
||||||
.replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => {
|
.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>')
|
||||||
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(/\n/g, '<br />');
|
.replace(/\n/g, '<br />');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -144,7 +130,7 @@ const FileIcon = ({ type }: { type: string }) => {
|
|||||||
return <Paperclip className={`${iconClass} text-gray-600`} />;
|
return <Paperclip className={`${iconClass} text-gray-600`} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover, 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 routeParams = useParams<{ requestId: string }>();
|
||||||
const effectiveRequestId = requestId || routeParams.requestId || '';
|
const effectiveRequestId = requestId || routeParams.requestId || '';
|
||||||
const [message, setMessage] = useState('');
|
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 [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null);
|
||||||
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
||||||
const [showAddApproverModal, setShowAddApproverModal] = 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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const socketRef = useRef<any>(null);
|
const socketRef = useRef<any>(null);
|
||||||
@ -186,7 +166,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
errors: []
|
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)
|
// Get request info (from props, all data comes from backend now)
|
||||||
const requestInfo = useMemo(() => {
|
const requestInfo = useMemo(() => {
|
||||||
@ -197,33 +177,20 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
}, [effectiveRequestId, requestTitle]);
|
}, [effectiveRequestId, requestTitle]);
|
||||||
|
|
||||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||||
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
const onlineParticipants = participants.filter(p => p.status === 'online');
|
const onlineParticipants = participants.filter(p => p.status === 'online');
|
||||||
const filteredMessages = messages.filter(msg =>
|
const filteredMessages = messages.filter(msg =>
|
||||||
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine if current user is a spectator (when not passed as prop)
|
// Log when participants change
|
||||||
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
|
|
||||||
useEffect(() => {
|
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]);
|
}, [participants]);
|
||||||
|
|
||||||
// Load initial messages from backend (only if not provided by parent)
|
// Load initial messages from backend (only if not provided by parent)
|
||||||
@ -233,6 +200,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoadingMessages(true);
|
||||||
const rows = await getWorkNotes(effectiveRequestId);
|
const rows = await getWorkNotes(effectiveRequestId);
|
||||||
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||||
const noteUserId = m.userId || m.user_id;
|
const noteUserId = m.userId || m.user_id;
|
||||||
@ -258,8 +226,11 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
};
|
};
|
||||||
}) : [];
|
}) : [];
|
||||||
setMessages(mapped as any);
|
setMessages(mapped as any);
|
||||||
|
console.log(`[WorkNoteChat] Loaded ${mapped.length} messages from backend`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WorkNoteChat] Failed to load messages:', error);
|
console.error('[WorkNoteChat] Failed to load messages:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMessages(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [effectiveRequestId, currentUserId, externalMessages]);
|
}, [effectiveRequestId, currentUserId, externalMessages]);
|
||||||
@ -340,19 +311,23 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if participants are already loaded (prevents resetting on tab switch)
|
// Skip if participants are already loaded (prevents resetting on tab switch)
|
||||||
if (participantsLoadedRef.current) {
|
if (participantsLoadedRef.current) {
|
||||||
|
console.log('[WorkNoteChat] Participants already loaded, skipping reload');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveRequestId) {
|
if (!effectiveRequestId) {
|
||||||
|
console.log('[WorkNoteChat] No requestId, skipping participants load');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log('[WorkNoteChat] Fetching participants from backend...');
|
||||||
const details = await getWorkflowDetails(effectiveRequestId);
|
const details = await getWorkflowDetails(effectiveRequestId);
|
||||||
const rows = Array.isArray(details?.participants) ? details.participants : [];
|
const rows = Array.isArray(details?.participants) ? details.participants : [];
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
console.log('[WorkNoteChat] No participants found in backend response');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -370,7 +345,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
} as any;
|
} 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;
|
participantsLoadedRef.current = true;
|
||||||
setParticipants(mapped);
|
setParticipants(mapped);
|
||||||
|
|
||||||
@ -379,7 +354,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
const requestOnlineUsers = () => {
|
const requestOnlineUsers = () => {
|
||||||
if (socketRef.current && socketRef.current.connected) {
|
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 });
|
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
|
||||||
retryCount++;
|
retryCount++;
|
||||||
// Retry a few times to ensure we get the list
|
// 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);
|
setTimeout(requestOnlineUsers, 500);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Socket not ready - retrying silently
|
console.log('[WorkNoteChat] ⚠️ Socket not ready, will retry in 200ms... (attempt', retryCount + 1, ')');
|
||||||
retryCount++;
|
retryCount++;
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
setTimeout(requestOnlineUsers, 200);
|
setTimeout(requestOnlineUsers, 200);
|
||||||
@ -407,6 +382,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
return () => {
|
return () => {
|
||||||
// Don't reset on unmount, only on request change
|
// Don't reset on unmount, only on request change
|
||||||
if (effectiveRequestId) {
|
if (effectiveRequestId) {
|
||||||
|
console.log('[WorkNoteChat] Request changed, will reload participants on next mount');
|
||||||
participantsLoadedRef.current = false;
|
participantsLoadedRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -430,7 +406,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDocumentPolicy = async () => {
|
const loadDocumentPolicy = async () => {
|
||||||
try {
|
try {
|
||||||
const configs = await getPublicConfigurations('DOCUMENT_POLICY');
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||||
const configMap: Record<string, string> = {};
|
const configMap: Record<string, string> = {};
|
||||||
configs.forEach((c: AdminConfiguration) => {
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
configMap[c.configKey] = c.configValue;
|
configMap[c.configKey] = c.configValue;
|
||||||
@ -466,42 +442,62 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
// Get backend URL from environment (same as API calls)
|
||||||
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
// 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)
|
// Only join room if not skipped (standalone mode)
|
||||||
if (!skipSocketJoin) {
|
if (!skipSocketJoin) {
|
||||||
|
console.log('[WorkNoteChat] 🚪 About to join request room - requestId:', joinedId, 'userId:', currentUserId, 'socketId:', s.id);
|
||||||
joinRequestRoom(s, joinedId, currentUserId);
|
joinRequestRoom(s, joinedId, currentUserId);
|
||||||
|
console.log('[WorkNoteChat] ✅ Emitted join:request event (standalone mode)');
|
||||||
|
|
||||||
// Mark self as online immediately after joining room
|
// Mark self as online immediately after joining room
|
||||||
setParticipants(prev => {
|
setParticipants(prev => {
|
||||||
const updated = prev.map(p =>
|
const updated = prev.map(p =>
|
||||||
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : 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;
|
return updated;
|
||||||
});
|
});
|
||||||
} else {
|
} 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)
|
// Still mark self as online even when embedded (parent handles socket but we track presence)
|
||||||
setParticipants(prev => {
|
setParticipants(prev => {
|
||||||
const updated = prev.map(p =>
|
const updated = prev.map(p =>
|
||||||
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : 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;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new work notes
|
// Handle new work notes
|
||||||
const noteHandler = (payload: any) => {
|
const noteHandler = (payload: any) => {
|
||||||
|
console.log('[WorkNoteChat] 📨 Received worknote:new event:', payload);
|
||||||
const n = payload?.note || payload;
|
const n = payload?.note || payload;
|
||||||
if (!n) {
|
if (!n) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ No note data in payload');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteId = n.noteId || n.id;
|
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
|
// Prevent duplicates: check if message with same noteId already exists
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
if (prev.some(m => m.id === noteId)) {
|
if (prev.some(m => m.id === noteId)) {
|
||||||
|
console.log('[WorkNoteChat] ⏭️ Duplicate note, skipping:', noteId);
|
||||||
return prev; // Already exists, don't add
|
return prev; // Already exists, don't add
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -531,96 +527,123 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
})) : undefined
|
})) : undefined
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] ✅ Adding new message to state:', newMessage.id);
|
||||||
return [...prev, newMessage];
|
return [...prev, newMessage];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle presence: user joined
|
// Handle presence: user joined
|
||||||
const presenceJoinHandler = (data: { userId: string; requestId: string }) => {
|
const presenceJoinHandler = (data: { userId: string; requestId: string }) => {
|
||||||
|
console.log('[WorkNoteChat] 🟢 presence:join received - userId:', data.userId, 'requestId:', data.requestId);
|
||||||
setParticipants(prev => {
|
setParticipants(prev => {
|
||||||
if (prev.length === 0) {
|
if (prev.length === 0) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ Cannot update presence:join - no participants loaded yet');
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const participant = prev.find(p => (p as any).userId === data.userId);
|
const participant = prev.find(p => (p as any).userId === data.userId);
|
||||||
if (!participant) {
|
if (!participant) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ User not found in participants list:', data.userId);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const updated = prev.map(p =>
|
const updated = prev.map(p =>
|
||||||
(p as any).userId === data.userId ? { ...p, status: 'online' as const } : 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;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle presence: user left
|
// Handle presence: user left
|
||||||
const presenceLeaveHandler = (data: { userId: string; requestId: string }) => {
|
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
|
// Never mark self as offline in own browser
|
||||||
if (data.userId === currentUserId) {
|
if (data.userId === currentUserId) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ Ignoring presence:leave for self - staying online in own view');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setParticipants(prev => {
|
setParticipants(prev => {
|
||||||
if (prev.length === 0) {
|
if (prev.length === 0) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ Cannot update presence:leave - no participants loaded yet');
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const participant = prev.find(p => (p as any).userId === data.userId);
|
const participant = prev.find(p => (p as any).userId === data.userId);
|
||||||
if (!participant) {
|
if (!participant) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ User not found in participants list:', data.userId);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const updated = prev.map(p =>
|
const updated = prev.map(p =>
|
||||||
(p as any).userId === data.userId ? { ...p, status: 'offline' as const } : 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;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle initial online users list
|
// Handle initial online users list
|
||||||
const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => {
|
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 => {
|
setParticipants(prev => {
|
||||||
if (prev.length === 0) {
|
if (prev.length === 0) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ No participants loaded yet, cannot update online status. Response will be ignored.');
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updating online status - logging removed
|
console.log('[WorkNoteChat] 📊 Updating online status for', prev.length, 'participants');
|
||||||
const updated = prev.map(p => {
|
const updated = prev.map(p => {
|
||||||
const pUserId = (p as any).userId || '';
|
const pUserId = (p as any).userId || '';
|
||||||
const isCurrentUserSelf = pUserId === currentUserId;
|
const isCurrentUserSelf = pUserId === currentUserId;
|
||||||
|
|
||||||
// Always keep self as online in own browser
|
// Always keep self as online in own browser
|
||||||
if (isCurrentUserSelf) {
|
if (isCurrentUserSelf) {
|
||||||
|
console.log(`[WorkNoteChat] 🟢 ${p.name} (YOU - always online in own view)`);
|
||||||
return { ...p, status: 'online' as const };
|
return { ...p, status: 'online' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOnline = data.userIds.includes(pUserId);
|
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 };
|
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;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle socket reconnection
|
// Handle socket reconnection
|
||||||
const connectHandler = () => {
|
const connectHandler = () => {
|
||||||
|
console.log('[WorkNoteChat] 🔌 Socket connected/reconnected');
|
||||||
|
|
||||||
// Mark self as online on connection
|
// Mark self as online on connection
|
||||||
setParticipants(prev => {
|
setParticipants(prev => {
|
||||||
const updated = prev.map(p =>
|
const updated = prev.map(p =>
|
||||||
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : 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;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rejoin room if needed
|
// Rejoin room if needed
|
||||||
if (!skipSocketJoin) {
|
if (!skipSocketJoin) {
|
||||||
joinRequestRoom(s, joinedId, currentUserId);
|
joinRequestRoom(s, joinedId, currentUserId);
|
||||||
|
console.log('[WorkNoteChat] 🔄 Rejoined request room on reconnection');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request online users on connection with multiple retries
|
// Request online users on connection with multiple retries
|
||||||
if (participantsLoadedRef.current) {
|
if (participantsLoadedRef.current) {
|
||||||
|
console.log('[WorkNoteChat] 📡 Requesting online users after connection...');
|
||||||
s.emit('request:online-users', { requestId: joinedId });
|
s.emit('request:online-users', { requestId: joinedId });
|
||||||
|
|
||||||
// Send additional requests with delay to ensure we get the response
|
// 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 }), 300);
|
||||||
setTimeout(() => s.emit('request:online-users', { requestId: joinedId }), 800);
|
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
|
// 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')) {
|
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('connect', connectHandler);
|
||||||
s.on('disconnect', disconnectHandler);
|
s.on('disconnect', disconnectHandler);
|
||||||
s.on('error', errorHandler);
|
s.on('error', errorHandler);
|
||||||
@ -653,26 +676,35 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
s.on('presence:leave', presenceLeaveHandler);
|
s.on('presence:leave', presenceLeaveHandler);
|
||||||
s.on('presence:online', presenceOnlineHandler);
|
s.on('presence:online', presenceOnlineHandler);
|
||||||
s.onAny(anyEventHandler); // Debug: catch all events
|
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
|
// Store socket in ref for coordination with participants loading
|
||||||
socketRef.current = s;
|
socketRef.current = s;
|
||||||
|
|
||||||
// Always request online users after socket is ready
|
// Always request online users after socket is ready
|
||||||
|
console.log('[WorkNoteChat] 🔌 Socket ready and listeners attached, socket.connected:', s.connected);
|
||||||
if (s.connected) {
|
if (s.connected) {
|
||||||
if (participantsLoadedRef.current) {
|
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 });
|
s.emit('request:online-users', { requestId: joinedId });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
console.log('[WorkNoteChat] 📡 Retry 1: Requesting online users...');
|
||||||
s.emit('request:online-users', { requestId: joinedId });
|
s.emit('request:online-users', { requestId: joinedId });
|
||||||
}, 300);
|
}, 300);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
console.log('[WorkNoteChat] 📡 Retry 2: Requesting online users...');
|
||||||
s.emit('request:online-users', { requestId: joinedId });
|
s.emit('request:online-users', { requestId: joinedId });
|
||||||
}, 800);
|
}, 800);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
console.log('[WorkNoteChat] 📡 Final retry: Requesting online users...');
|
||||||
s.emit('request:online-users', { requestId: joinedId });
|
s.emit('request:online-users', { requestId: joinedId });
|
||||||
}, 1500);
|
}, 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
|
// cleanup
|
||||||
@ -688,9 +720,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
// Only leave room if we joined it
|
// Only leave room if we joined it
|
||||||
if (!skipSocketJoin) {
|
if (!skipSocketJoin) {
|
||||||
leaveRequestRoom(s, joinedId);
|
leaveRequestRoom(s, joinedId);
|
||||||
|
console.log('[WorkNoteChat] 🚪 Emitting leave:request for room (standalone mode)');
|
||||||
}
|
}
|
||||||
socketRef.current = null;
|
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;
|
(window as any).__wn_cleanup = cleanup;
|
||||||
} catch {}
|
} catch {}
|
||||||
@ -711,11 +744,16 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
const participant = participants.find(p =>
|
const participant = participants.find(p =>
|
||||||
p.name.toLowerCase().includes(mentionedName.toLowerCase())
|
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;
|
return (participant as any)?.userId;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.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 => ({
|
const attachments = selectedFiles.map(file => ({
|
||||||
name: file.name,
|
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()
|
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);
|
setMessages(sorted);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WorkNoteChat] Error mapping messages:', err);
|
console.error('[WorkNoteChat] Error mapping messages:', err);
|
||||||
@ -859,12 +897,19 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getWorkNotes(effectiveRequestId);
|
const rows = await getWorkNotes(effectiveRequestId);
|
||||||
|
console.log('[WorkNoteChat] Loaded work notes from backend:', rows);
|
||||||
|
|
||||||
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||||
const userName = m.userName || m.user_name || 'User';
|
const userName = m.userName || m.user_name || 'User';
|
||||||
const userRole = m.userRole || m.user_role; // Get role directly from backend
|
const userRole = m.userRole || m.user_role; // Get role directly from backend
|
||||||
const participantRole = getFormattedRole(userRole);
|
const participantRole = getFormattedRole(userRole);
|
||||||
const noteUserId = m.userId || m.user_id;
|
const noteUserId = m.userId || m.user_id;
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] Mapping note:', {
|
||||||
|
rawNote: m,
|
||||||
|
extracted: { userName, userRole, participantRole }
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
||||||
user: {
|
user: {
|
||||||
@ -1010,26 +1055,15 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
|
|
||||||
// Request updated online users list from server to get correct status
|
// Request updated online users list from server to get correct status
|
||||||
if (socketRef.current && socketRef.current.connected) {
|
if (socketRef.current && socketRef.current.connected) {
|
||||||
|
console.log('[WorkNoteChat] 📡 Requesting online users after adding spectator...');
|
||||||
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
|
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowAddSpectatorModal(false);
|
setShowAddSpectatorModal(false);
|
||||||
// Show success modal
|
alert('Spectator added successfully');
|
||||||
setActionStatus({
|
|
||||||
success: true,
|
|
||||||
title: 'Spectator Added',
|
|
||||||
message: 'Spectator added successfully. They can now view this request.'
|
|
||||||
});
|
|
||||||
setShowActionStatusModal(true);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to add spectator:', error);
|
console.error('Failed to add spectator:', error);
|
||||||
// Show error modal
|
alert(error?.response?.data?.error || 'Failed to add spectator');
|
||||||
setActionStatus({
|
|
||||||
success: false,
|
|
||||||
title: 'Failed to Add Spectator',
|
|
||||||
message: error?.response?.data?.error || 'Failed to add spectator. Please try again.'
|
|
||||||
});
|
|
||||||
setShowActionStatusModal(true);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1073,22 +1107,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowAddApproverModal(false);
|
setShowAddApproverModal(false);
|
||||||
// Show success modal
|
alert(`Approver added successfully at Level ${level} with ${tatHours}h TAT`);
|
||||||
setActionStatus({
|
|
||||||
success: true,
|
|
||||||
title: 'Approver Added',
|
|
||||||
message: `Approver added successfully at Level ${level} with ${tatHours}h TAT`
|
|
||||||
});
|
|
||||||
setShowActionStatusModal(true);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to add approver:', error);
|
console.error('Failed to add approver:', error);
|
||||||
// Show error modal
|
alert(error?.response?.data?.error || 'Failed to add approver');
|
||||||
setActionStatus({
|
|
||||||
success: false,
|
|
||||||
title: 'Failed to Add Approver',
|
|
||||||
message: error?.response?.data?.error || 'Failed to add approver. Please try again.'
|
|
||||||
});
|
|
||||||
setShowActionStatusModal(true);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1145,23 +1167,15 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
|
|
||||||
const extractMentions = (text: string): string[] => {
|
const extractMentions = (text: string): string[] => {
|
||||||
// Use the SAME regex pattern as formatMessage to ensure consistency
|
// Use the SAME regex pattern as formatMessage to ensure consistency
|
||||||
// Only one space allowed: @word or @word word (first name + last name)
|
const mentionRegex = /@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g;
|
||||||
const mentionRegex = /@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g;
|
|
||||||
const mentions: string[] = [];
|
const mentions: string[] = [];
|
||||||
let match;
|
let match;
|
||||||
while ((match = mentionRegex.exec(text)) !== null) {
|
while ((match = mentionRegex.exec(text)) !== null) {
|
||||||
if (match[1]) {
|
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;
|
return mentions;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1386,14 +1400,14 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!attachmentId) {
|
if (!attachmentId) {
|
||||||
toast.error('Cannot download: Attachment ID missing');
|
alert('Cannot download: Attachment ID missing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadWorkNoteAttachment(attachmentId);
|
await downloadWorkNoteAttachment(attachmentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to download file');
|
alert('Failed to download file');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Download file"
|
title="Download file"
|
||||||
@ -1494,43 +1508,34 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
{/* Mention Suggestions Dropdown - Shows above textarea */}
|
{/* 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 lastAtIndex = message.lastIndexOf('@');
|
||||||
const hasAt = lastAtIndex >= 0;
|
const hasAt = lastAtIndex >= 0;
|
||||||
|
const textAfterAt = hasAt ? message.slice(lastAtIndex + 1) : '';
|
||||||
|
|
||||||
if (!hasAt) return null;
|
// Don't show if:
|
||||||
|
// 1. No @ found
|
||||||
// Get text after the last @
|
// 2. Text after @ is too long (>20 chars)
|
||||||
const textAfterAt = message.slice(lastAtIndex + 1);
|
// 3. Text after @ ends with a space (completed mention)
|
||||||
|
// 4. Text after @ contains a space (already selected a user)
|
||||||
// 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();
|
|
||||||
const endsWithSpace = textAfterAt.endsWith(' ');
|
const endsWithSpace = textAfterAt.endsWith(' ');
|
||||||
const hasNonSpaceChars = trimmedAfterAt.length > 0;
|
const containsSpace = textAfterAt.trim().includes(' ');
|
||||||
|
|
||||||
// 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 shouldShowDropdown = hasAt &&
|
const shouldShowDropdown = hasAt &&
|
||||||
textAfterAt.length <= 20 &&
|
textAfterAt.length <= 20 &&
|
||||||
!containsSpaceInMiddle &&
|
!endsWithSpace &&
|
||||||
!isCompletedMention;
|
!containsSpace;
|
||||||
|
|
||||||
|
console.log('[Mention Debug]', {
|
||||||
|
hasAt,
|
||||||
|
textAfterAt: `"${textAfterAt}"`,
|
||||||
|
endsWithSpace,
|
||||||
|
containsSpace,
|
||||||
|
shouldShowDropdown,
|
||||||
|
participantsCount: participants.length
|
||||||
|
});
|
||||||
|
|
||||||
if (!shouldShowDropdown) return null;
|
if (!shouldShowDropdown) return null;
|
||||||
|
|
||||||
// Use trimmed text for search (ignore trailing spaces)
|
const searchTerm = textAfterAt.toLowerCase();
|
||||||
const searchTerm = trimmedAfterAt.toLowerCase();
|
|
||||||
const filteredParticipants = participants.filter(p => {
|
const filteredParticipants = participants.filter(p => {
|
||||||
// Exclude current user from mention suggestions
|
// Exclude current user from mention suggestions
|
||||||
const isCurrentUserInList = (p as any).userId === currentUserId;
|
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
|
return true; // Show all if no search term
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[Mention Debug] Filtered participants:', filteredParticipants.length);
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
<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) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Find the last @ and replace everything from @ to end with the new mention
|
|
||||||
const lastAt = message.lastIndexOf('@');
|
const lastAt = message.lastIndexOf('@');
|
||||||
const before = message.slice(0, lastAt);
|
const before = message.slice(0, lastAt);
|
||||||
// Add the mention with a space after for easy continuation
|
|
||||||
setMessage(before + '@' + participant.name + ' ');
|
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"
|
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,8 +1746,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions Section - Hide for spectators */}
|
|
||||||
{!effectiveIsSpectator && (
|
|
||||||
<div className="p-4 sm:p-6 flex-shrink-0">
|
<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>
|
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -1777,7 +1780,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
</Button> */}
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1795,8 +1797,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Spectator Modal - Hide for spectators */}
|
{/* Add Spectator Modal */}
|
||||||
{!effectiveIsSpectator && (
|
|
||||||
<AddSpectatorModal
|
<AddSpectatorModal
|
||||||
open={showAddSpectatorModal}
|
open={showAddSpectatorModal}
|
||||||
onClose={() => setShowAddSpectatorModal(false)}
|
onClose={() => setShowAddSpectatorModal(false)}
|
||||||
@ -1805,10 +1806,9 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
requestTitle={requestInfo.title}
|
requestTitle={requestInfo.title}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add Approver Modal - Hide for spectators */}
|
{/* Add Approver Modal */}
|
||||||
{!effectiveIsSpectator && isInitiator && (
|
{isInitiator && (
|
||||||
<AddApproverModal
|
<AddApproverModal
|
||||||
open={showAddApproverModal}
|
open={showAddApproverModal}
|
||||||
onClose={() => setShowAddApproverModal(false)}
|
onClose={() => setShowAddApproverModal(false)}
|
||||||
@ -1817,8 +1817,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
requestTitle={requestInfo.title}
|
requestTitle={requestInfo.title}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
maxApprovalLevels={maxApprovalLevels}
|
|
||||||
onPolicyViolation={onPolicyViolation}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1863,15 +1861,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
import { FilePreview } from '@/components/common/FilePreview';
|
import { FilePreview } from '@/components/common/FilePreview';
|
||||||
@ -54,6 +53,7 @@ interface Message {
|
|||||||
interface WorkNoteChatSimpleProps {
|
interface WorkNoteChatSimpleProps {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
messages?: any[];
|
messages?: any[];
|
||||||
|
loading?: boolean;
|
||||||
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
|
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 [message, setMessage] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
@ -142,8 +142,8 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
const base = (import.meta as any).env.VITE_BASE_URL || window.location.origin;
|
||||||
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
const s = getSocket(base);
|
||||||
|
|
||||||
joinRequestRoom(s, joinedId, currentUserId || undefined);
|
joinRequestRoom(s, joinedId, currentUserId || undefined);
|
||||||
|
|
||||||
@ -151,8 +151,11 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
const n = payload?.note || payload;
|
const n = payload?.note || payload;
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] Received note via socket:', n);
|
||||||
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
|
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
|
||||||
|
console.log('[WorkNoteChat] Duplicate detected, skipping');
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,6 +176,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
isCurrentUser: noteUserId === currentUserId
|
isCurrentUser: noteUserId === currentUserId
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] Adding new message:', newMsg);
|
||||||
return [...prev, newMsg];
|
return [...prev, newMsg];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -262,6 +266,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getWorkNotes(requestId);
|
const rows = await getWorkNotes(requestId);
|
||||||
|
console.log('[WorkNoteChat] Loaded work notes:', rows);
|
||||||
|
|
||||||
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||||
const userName = m.userName || m.user_name || 'User';
|
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) => {
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -500,14 +517,14 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!attachmentId) {
|
if (!attachmentId) {
|
||||||
toast.error('Cannot download: Attachment ID missing');
|
alert('Cannot download: Attachment ID missing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadWorkNoteAttachment(attachmentId);
|
await downloadWorkNoteAttachment(attachmentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to download file');
|
alert('Failed to download file');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Download file"
|
title="Download file"
|
||||||
|
|||||||
@ -1,16 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon } from 'lucide-react';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||||
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';
|
|
||||||
|
|
||||||
export interface ApprovalStep {
|
export interface ApprovalStep {
|
||||||
step: number;
|
step: number;
|
||||||
@ -39,9 +32,7 @@ interface ApprovalStepCardProps {
|
|||||||
approval?: any; // Raw approval data from backend
|
approval?: any; // Raw approval data from backend
|
||||||
isCurrentUser?: boolean;
|
isCurrentUser?: boolean;
|
||||||
isInitiator?: boolean;
|
isInitiator?: boolean;
|
||||||
isCurrentLevel?: boolean; // Whether this step is the current active level
|
|
||||||
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
|
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
|
||||||
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
|
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,12 +40,12 @@ interface ApprovalStepCardProps {
|
|||||||
const formatWorkingHours = (hours: number): string => {
|
const formatWorkingHours = (hours: number): string => {
|
||||||
const WORKING_HOURS_PER_DAY = 8;
|
const WORKING_HOURS_PER_DAY = 8;
|
||||||
if (hours < WORKING_HOURS_PER_DAY) {
|
if (hours < WORKING_HOURS_PER_DAY) {
|
||||||
return formatHoursMinutes(hours);
|
return `${hours.toFixed(1)}h`;
|
||||||
}
|
}
|
||||||
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
||||||
const remainingHours = hours % WORKING_HOURS_PER_DAY;
|
const remainingHours = hours % WORKING_HOURS_PER_DAY;
|
||||||
if (remainingHours > 0) {
|
if (remainingHours > 0) {
|
||||||
return `${days}d ${formatHoursMinutes(remainingHours)}`;
|
return `${days}d ${remainingHours.toFixed(1)}h`;
|
||||||
}
|
}
|
||||||
return `${days}d`;
|
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" />;
|
return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />;
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />;
|
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 'pending':
|
||||||
case 'in-review':
|
case 'in-review':
|
||||||
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
|
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
|
||||||
@ -85,86 +74,18 @@ export function ApprovalStepCard({
|
|||||||
approval,
|
approval,
|
||||||
isCurrentUser = false,
|
isCurrentUser = false,
|
||||||
isInitiator = false,
|
isInitiator = false,
|
||||||
isCurrentLevel = false,
|
|
||||||
onSkipApprover,
|
onSkipApprover,
|
||||||
onRefresh,
|
|
||||||
testId = 'approval-step'
|
testId = 'approval-step'
|
||||||
}: ApprovalStepCardProps) {
|
}: 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 isActive = step.status === 'pending' || step.status === 'in-review';
|
||||||
const isCompleted = step.status === 'approved';
|
const isCompleted = step.status === 'approved';
|
||||||
const isRejected = step.status === 'rejected';
|
const isRejected = step.status === 'rejected';
|
||||||
const isWaiting = step.status === 'waiting';
|
const isWaiting = step.status === 'waiting';
|
||||||
const isPaused = step.status === 'paused';
|
|
||||||
|
|
||||||
const tatHours = Number(step.tatHours || 0);
|
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;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative p-3 sm:p-4 md:p-5 rounded-lg border-2 transition-all ${
|
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="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 ${
|
<div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
|
||||||
step.isSkipped ? 'bg-orange-100' :
|
step.isSkipped ? 'bg-orange-100' :
|
||||||
isPaused ? 'bg-yellow-100' :
|
|
||||||
isActive ? 'bg-blue-100' :
|
isActive ? 'bg-blue-100' :
|
||||||
isCompleted ? 'bg-green-100' :
|
isCompleted ? 'bg-green-100' :
|
||||||
isRejected ? 'bg-red-100' :
|
isRejected ? 'bg-red-100' :
|
||||||
@ -269,62 +189,21 @@ export function ApprovalStepCard({
|
|||||||
{(() => {
|
{(() => {
|
||||||
// Calculate actual progress percentage based on time used
|
// Calculate actual progress percentage based on time used
|
||||||
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
|
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
|
||||||
const displayPercentage = Math.min(100, progressPercentage);
|
const progressPercentage = tatHours > 0 ? Math.min(100, (actualHours / tatHours) * 100) : 0;
|
||||||
|
|
||||||
// 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';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Progress
|
<Progress
|
||||||
value={displayPercentage}
|
value={progressPercentage}
|
||||||
className="h-2 bg-gray-200"
|
className="h-2 bg-gray-200"
|
||||||
indicatorClassName={getIndicatorColor()}
|
|
||||||
data-testid={`${testId}-progress-bar`}
|
data-testid={`${testId}-progress-bar`}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-green-600 font-semibold">
|
||||||
<span className={`font-semibold ${getProgressTextColor()}`}>
|
{progressPercentage.toFixed(1)}% of TAT used
|
||||||
{Math.round(displayPercentage)}% of TAT used
|
|
||||||
</span>
|
</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>
|
|
||||||
{savedHours > 0 && (
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -332,17 +211,6 @@ export function ApprovalStepCard({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</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 */}
|
{/* Conclusion Remark */}
|
||||||
{step.comment && (
|
{step.comment && (
|
||||||
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Approver (including paused) - Show Real-time Progress from Backend */}
|
{/* Active Approver - Show Real-time Progress from Backend */}
|
||||||
{/* Only show SLA for the current level step, not future levels */}
|
{isActive && approval?.sla && (
|
||||||
{isCurrentLevel && (isActive || isPaused) && approval?.sla && (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-gray-600">Due by:</span>
|
<span className="text-gray-600">Due by:</span>
|
||||||
<span className="font-medium text-gray-900">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Approver - Time Tracking */}
|
{/* Current Approver - Time Tracking */}
|
||||||
<div className={`border rounded-lg p-3 ${
|
<div className={`border rounded-lg p-3 ${
|
||||||
isPaused ? 'bg-gray-100 border-gray-300' :
|
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
|
||||||
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
|
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
|
||||||
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
|
'bg-yellow-50 border-yellow-200'
|
||||||
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
|
|
||||||
'bg-green-50 border-green-200'
|
|
||||||
}`}>
|
}`}>
|
||||||
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
Current Approver - Time Tracking {isPaused && '(Paused)'}
|
Current Approver - Time Tracking
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2 text-xs mb-3">
|
<div className="space-y-2 text-xs mb-3">
|
||||||
@ -387,90 +252,38 @@ export function ApprovalStepCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Time used:</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="space-y-2">
|
<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
|
<Progress
|
||||||
value={approval.sla.percentageUsed}
|
value={approval.sla.percentageUsed}
|
||||||
className="h-3"
|
className={`h-3 ${
|
||||||
indicatorClassName={getActiveIndicatorColor()}
|
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`}
|
data-testid={`${testId}-sla-progress`}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<span className={`text-xs font-semibold ${
|
||||||
<span className={`text-xs font-semibold ${getActiveTextColor()}`}>
|
approval.sla.status === 'breached' ? 'text-red-600' :
|
||||||
Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used
|
approval.sla.status === 'critical' ? 'text-orange-600' :
|
||||||
|
'text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
Progress: {approval.sla.percentageUsed}% of TAT used
|
||||||
</span>
|
</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">
|
<span className="text-xs font-medium text-gray-700">
|
||||||
{approval.sla.remainingText} remaining
|
{approval.sla.remainingText} remaining
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{approval.sla.status === 'breached' && (
|
{approval.sla.status === 'breached' && (
|
||||||
<>
|
|
||||||
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
|
<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" />
|
<AlertOctagon className="w-4 h-4" />
|
||||||
Deadline Breached
|
Deadline Breached
|
||||||
</p>
|
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{approval.sla.status === 'critical' && (
|
{approval.sla.status === 'critical' && (
|
||||||
<p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5">
|
<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">
|
<div className="bg-white/50 rounded px-2 py-1">
|
||||||
<span className="text-gray-500">Allocated:</span>
|
<span className="text-gray-500">Allocated:</span>
|
||||||
<span className="ml-1 font-medium text-gray-900">
|
<span className="ml-1 font-medium text-gray-900">
|
||||||
{formatHoursMinutes(Number(alert.tatHoursAllocated || 0))}
|
{Number(alert.tatHoursAllocated || 0).toFixed(2)}h
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/50 rounded px-2 py-1">
|
<div className="bg-white/50 rounded px-2 py-1">
|
||||||
<span className="text-gray-500">Elapsed:</span>
|
<span className="text-gray-500">Elapsed:</span>
|
||||||
<span className="ml-1 font-medium text-gray-900">
|
<span className="ml-1 font-medium text-gray-900">
|
||||||
{formatHoursMinutes(Number(alert.tatHoursElapsed || 0))}
|
{Number(alert.tatHoursElapsed || 0).toFixed(2)}h
|
||||||
{alert.metadata?.tatTestMode && (
|
{alert.metadata?.tatTestMode && (
|
||||||
<span className="text-purple-600 ml-1">
|
<span className="text-purple-600 ml-1">
|
||||||
({(Number(alert.tatHoursElapsed || 0) * 60).toFixed(0)}m)
|
({(Number(alert.tatHoursElapsed || 0) * 60).toFixed(0)}m)
|
||||||
@ -594,7 +407,7 @@ export function ApprovalStepCard({
|
|||||||
<span className={`ml-1 font-medium ${
|
<span className={`ml-1 font-medium ${
|
||||||
(alert.tatHoursRemaining || 0) < 2 ? 'text-red-600' : 'text-gray-900'
|
(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 && (
|
{alert.metadata?.tatTestMode && (
|
||||||
<span className="text-purple-600 ml-1">
|
<span className="text-purple-600 ml-1">
|
||||||
({(Number(alert.tatHoursRemaining || 0) * 60).toFixed(0)}m)
|
({(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">
|
<div className="bg-white/50 rounded px-2 py-1">
|
||||||
<span className="text-gray-500">Due by:</span>
|
<span className="text-gray-500">Due by:</span>
|
||||||
<span className="ml-1 font-medium text-gray-900">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -643,9 +456,8 @@ export function ApprovalStepCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Skip Approver Button - Only show for initiator on pending/in-review levels (not when paused) */}
|
{/* Skip Approver Button - Only show for initiator on pending/in-review levels */}
|
||||||
{/* User must resume first before skipping */}
|
{isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
|
||||||
{isInitiator && !isPaused && (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">
|
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -662,60 +474,12 @@ export function ApprovalStepCard({
|
|||||||
Skip This Approver
|
Skip This Approver
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
|
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
|
||||||
{isPaused
|
Skip if approver is unavailable and move to next level
|
||||||
? 'Skip this approver to resume the workflow and move to next level'
|
|
||||||
: 'Skip if approver is unavailable and move to next level'
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/workflow/ClaimManagementDetail/index.ts
Normal file
1
src/components/workflow/ClaimManagementDetail/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ClaimManagementDetail } from './ClaimManagementDetail';
|
||||||
@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/workflow/ClaimManagementWizard/index.ts
Normal file
1
src/components/workflow/ClaimManagementWizard/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ClaimManagementWizard } from './ClaimManagementWizard';
|
||||||
@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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 →
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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';
|
|
||||||
|
|
||||||
@ -6,7 +6,7 @@ export interface DocumentData {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
name: string;
|
name: string;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
size?: string;
|
size: string;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
uploadedBy?: string;
|
uploadedBy?: string;
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
@ -48,9 +48,7 @@ export function DocumentCard({
|
|||||||
{document.name}
|
{document.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
|
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
|
||||||
{document.size && <span>{document.size} • </span>}
|
{document.size} • Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)}
|
||||||
{document.uploadedBy && <span>Uploaded by {document.uploadedBy} on </span>}
|
|
||||||
{formatDateTime(document.uploadedAt)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -9,7 +9,6 @@ import { createContext, useContext, useEffect, useState, ReactNode, useRef } fro
|
|||||||
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
|
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
|
||||||
import { TokenManager, isTokenExpired } from '../utils/tokenManager';
|
import { TokenManager, isTokenExpired } from '../utils/tokenManager';
|
||||||
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
|
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
|
||||||
import { tanflowLogout } from '../services/tanflowAuth';
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@ -46,10 +45,8 @@ interface AuthProviderProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if running on localhost
|
* Check if running on localhost
|
||||||
* Note: Function reserved for future use
|
|
||||||
* @internal - Reserved for future use
|
|
||||||
*/
|
*/
|
||||||
export const _isLocalhost = (): boolean => {
|
const isLocalhost = (): boolean => {
|
||||||
return (
|
return (
|
||||||
window.location.hostname === 'localhost' ||
|
window.location.hostname === 'localhost' ||
|
||||||
window.location.hostname === '127.0.0.1' ||
|
window.location.hostname === '127.0.0.1' ||
|
||||||
@ -75,6 +72,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const forceLogout = sessionStorage.getItem('__force_logout__');
|
const forceLogout = sessionStorage.getItem('__force_logout__');
|
||||||
|
|
||||||
if (logoutFlag === 'true' || forceLogout === 'true') {
|
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
|
// Remove flags
|
||||||
sessionStorage.removeItem('__logout_in_progress__');
|
sessionStorage.removeItem('__logout_in_progress__');
|
||||||
sessionStorage.removeItem('__force_logout__');
|
sessionStorage.removeItem('__force_logout__');
|
||||||
@ -96,100 +96,62 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
console.log('🔴 Logout complete - user should see login screen');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
|
if (urlParams.has('logout') || urlParams.has('okta_logged_out')) {
|
||||||
console.log('🚪 Logout parameter detected in URL, clearing all tokens');
|
console.log('🔴 Logout parameter in URL - clearing everything', {
|
||||||
|
hasLogout: urlParams.has('logout'),
|
||||||
|
hasOktaLoggedOut: urlParams.has('okta_logged_out'),
|
||||||
|
});
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
// Clear auth provider flag and logout-related flags
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
sessionStorage.removeItem('tanflow_auth_state');
|
|
||||||
sessionStorage.removeItem('__logout_in_progress__');
|
|
||||||
sessionStorage.removeItem('__force_logout__');
|
|
||||||
sessionStorage.removeItem('tanflow_logged_out');
|
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
// Don't clear sessionStorage completely - we might need logout flags
|
sessionStorage.clear();
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// Clean URL but preserve logout flags if they exist (for prompt=login)
|
// Clean URL but preserve okta_logged_out flag if it exists (for prompt=login)
|
||||||
const cleanParams = new URLSearchParams();
|
const cleanParams = new URLSearchParams();
|
||||||
if (urlParams.has('okta_logged_out')) {
|
if (urlParams.has('okta_logged_out')) {
|
||||||
cleanParams.set('okta_logged_out', 'true');
|
cleanParams.set('okta_logged_out', 'true');
|
||||||
}
|
}
|
||||||
if (urlParams.has('tanflow_logged_out')) {
|
|
||||||
cleanParams.set('tanflow_logged_out', 'true');
|
|
||||||
}
|
|
||||||
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
|
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
|
||||||
window.history.replaceState({}, document.title, newUrl);
|
window.history.replaceState({}, document.title, newUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
|
// PRIORITY 3: Check if this is a logout redirect by checking if all tokens are cleared
|
||||||
// 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
|
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
const userData = TokenManager.getUserData();
|
const userData = TokenManager.getUserData();
|
||||||
const hasAuthData = token || refreshToken || userData;
|
const hasAuthData = token || refreshToken || userData;
|
||||||
|
|
||||||
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
|
// If no auth data exists, we're likely after a logout - set unauthenticated state immediately
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Development: If no auth data exists, user is not authenticated
|
|
||||||
if (!hasAuthData) {
|
if (!hasAuthData) {
|
||||||
|
console.log('🔴 No auth data found - setting unauthenticated state');
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
|
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out
|
||||||
if (!isLoggingOut) {
|
if (!isLoggingOut) {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
} else {
|
} else {
|
||||||
|
console.log('🔴 Skipping checkAuthStatus - logout in progress');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [isLoggingOut]);
|
}, [isLoggingOut]);
|
||||||
|
|
||||||
// Silent refresh interval
|
// Silent refresh interval
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
|
||||||
|
|
||||||
const checkAndRefresh = async () => {
|
const checkAndRefresh = async () => {
|
||||||
if (isProductionMode) {
|
|
||||||
// In production, proactively refresh the session every 10 minutes
|
|
||||||
// The httpOnly cookie will be sent automatically
|
|
||||||
try {
|
|
||||||
await refreshTokenSilently();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Silent refresh failed:', error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// In development, check token expiration
|
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
if (token && isTokenExpired(token, 5)) {
|
if (token && isTokenExpired(token, 5)) {
|
||||||
// Token expires in less than 5 minutes, refresh it
|
// Token expires in less than 5 minutes, refresh it
|
||||||
@ -199,12 +161,10 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
console.error('Silent refresh failed:', error);
|
console.error('Silent refresh failed:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check every 10 minutes in production, 5 minutes in development
|
// Check every 5 minutes
|
||||||
const intervalMs = isProductionMode ? 10 * 60 * 1000 : 5 * 60 * 1000;
|
const interval = setInterval(checkAndRefresh, 5 * 60 * 1000);
|
||||||
const interval = setInterval(checkAndRefresh, intervalMs);
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
@ -219,57 +179,24 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCallback = async () => {
|
const handleCallback = async () => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
// Check if this is a logout redirect (from Tanflow post-logout redirect)
|
|
||||||
// If it has logout parameters but no code, it's a logout redirect, not a login callback
|
|
||||||
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
|
|
||||||
// This is a logout redirect, not a login callback
|
|
||||||
// Redirect to home page - the mount useEffect will handle logout cleanup
|
|
||||||
console.log('🚪 Logout redirect detected in callback, redirecting to home');
|
|
||||||
// Extract the logout flags from current URL
|
|
||||||
const logoutFlags = new URLSearchParams();
|
|
||||||
if (urlParams.has('tanflow_logged_out')) logoutFlags.set('tanflow_logged_out', 'true');
|
|
||||||
if (urlParams.has('okta_logged_out')) logoutFlags.set('okta_logged_out', 'true');
|
|
||||||
if (urlParams.has('logout')) logoutFlags.set('logout', urlParams.get('logout') || Date.now().toString());
|
|
||||||
const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now();
|
|
||||||
window.location.replace(redirectUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as processed immediately to prevent duplicate calls
|
// Mark as processed immediately to prevent duplicate calls
|
||||||
callbackProcessedRef.current = true;
|
callbackProcessedRef.current = true;
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
const errorParam = urlParams.get('error');
|
const errorParam = urlParams.get('error');
|
||||||
|
|
||||||
// Clean URL immediately to prevent re-running on re-renders
|
// Clean URL immediately to prevent re-running on re-renders
|
||||||
window.history.replaceState({}, document.title, '/login/callback');
|
window.history.replaceState({}, document.title, '/login/callback');
|
||||||
|
|
||||||
// Detect provider from sessionStorage
|
|
||||||
const authProvider = sessionStorage.getItem('auth_provider');
|
|
||||||
|
|
||||||
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
|
|
||||||
if (authProvider === 'tanflow') {
|
|
||||||
// Clear the provider flag and let TanflowCallback handle it
|
|
||||||
// Reset ref so TanflowCallback can process
|
|
||||||
callbackProcessedRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle OKTA callback (default)
|
|
||||||
if (errorParam) {
|
if (errorParam) {
|
||||||
setError(new Error(`Authentication error: ${errorParam}`));
|
setError(new Error(`Authentication error: ${errorParam}`));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// Clear provider flag
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// Clear provider flag
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,6 +209,12 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// This is the frontend callback URL, NOT the backend URL
|
// This is the frontend callback URL, NOT the backend URL
|
||||||
// Backend will use this same URI when exchanging code with Okta
|
// Backend will use this same URI when exchanging code with Okta
|
||||||
const redirectUri = `${window.location.origin}/login/callback`;
|
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);
|
const result = await exchangeCodeForTokens(code, redirectUri);
|
||||||
|
|
||||||
@ -289,9 +222,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Clear provider flag after successful authentication
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
|
|
||||||
// Clean URL after success
|
// Clean URL after success
|
||||||
window.history.replaceState({}, document.title, '/');
|
window.history.replaceState({}, document.title, '/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -299,8 +229,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setError(err);
|
setError(err);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
// Clear provider flag on error
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
// Reset ref on error so user can retry if needed
|
// Reset ref on error so user can retry if needed
|
||||||
callbackProcessedRef.current = false;
|
callbackProcessedRef.current = false;
|
||||||
} finally {
|
} finally {
|
||||||
@ -314,67 +242,22 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const checkAuthStatus = async () => {
|
const checkAuthStatus = async () => {
|
||||||
// Don't check auth status if we're in the middle of logging out
|
// Don't check auth status if we're in the middle of logging out
|
||||||
if (isLoggingOut) {
|
if (isLoggingOut) {
|
||||||
|
console.log('🔴 Skipping checkAuthStatus - logout in progress');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
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 token = TokenManager.getAccessToken();
|
||||||
const storedUser = TokenManager.getUserData();
|
const storedUser = TokenManager.getUserData();
|
||||||
|
|
||||||
|
console.log('🔍 Checking auth status:', { hasToken: !!token, hasUser: !!storedUser, isLoggingOut });
|
||||||
|
|
||||||
// If no token at all, user is not authenticated
|
// If no token at all, user is not authenticated
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
console.log('🔍 No token found - setting unauthenticated');
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -461,12 +344,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const scope = 'openid profile email';
|
const scope = 'openid profile email';
|
||||||
const state = Math.random().toString(36).substring(7);
|
const state = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
// Store provider type to identify OKTA callback
|
|
||||||
sessionStorage.setItem('auth_provider', 'okta');
|
|
||||||
|
|
||||||
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
|
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out');
|
||||||
|
|
||||||
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
||||||
`client_id=${clientId}&` +
|
`client_id=${clientId}&` +
|
||||||
@ -479,6 +359,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// This ensures Okta requires login even if a session still exists
|
// This ensures Okta requires login even if a session still exists
|
||||||
if (isAfterLogout) {
|
if (isAfterLogout) {
|
||||||
authUrl += `&prompt=login`;
|
authUrl += `&prompt=login`;
|
||||||
|
console.log('🔐 Adding prompt=login to force re-authentication after logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = authUrl;
|
window.location.href = authUrl;
|
||||||
@ -489,85 +370,79 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
console.log('🚪 LOGOUT FUNCTION CALLED - Starting logout process');
|
||||||
|
console.log('🚪 Current auth state:', { isAuthenticated, hasUser: !!user, isLoading });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// CRITICAL: Get id_token from TokenManager before clearing anything
|
// CRITICAL: Get id_token from TokenManager before clearing anything
|
||||||
// Needed for both Okta and Tanflow logout endpoints
|
// Okta logout endpoint works better with id_token_hint to properly end the session
|
||||||
const idToken = TokenManager.getIdToken();
|
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
|
// Set logout flag to prevent auto-authentication after redirect
|
||||||
// This must be set BEFORE clearing storage so it survives
|
// This must be set BEFORE clearing storage so it survives
|
||||||
sessionStorage.setItem('__logout_in_progress__', 'true');
|
sessionStorage.setItem('__logout_in_progress__', 'true');
|
||||||
sessionStorage.setItem('__force_logout__', 'true');
|
sessionStorage.setItem('__force_logout__', 'true');
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
|
|
||||||
|
console.log('🚪 Step 1: Resetting auth state...');
|
||||||
// Reset auth state FIRST to prevent any re-authentication
|
// Reset auth state FIRST to prevent any re-authentication
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
|
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
|
// 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
|
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
|
||||||
try {
|
try {
|
||||||
|
console.log('🚪 Step 2: Calling backend logout API to clear httpOnly cookies...');
|
||||||
await logoutApi();
|
await logoutApi();
|
||||||
console.log('🚪 Backend logout API called successfully');
|
console.log('🚪 Step 2: Backend logout API completed - httpOnly cookies should be cleared');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('🚪 Logout API error:', err);
|
console.error('🚪 Logout API error:', err);
|
||||||
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
||||||
// Continue with logout even if API call fails
|
// Continue with logout even if API call fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens
|
// Clear all authentication data EXCEPT the logout flags and id_token (we need it for Okta logout)
|
||||||
|
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 logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
|
||||||
const forceLogout = sessionStorage.getItem('__force_logout__');
|
const forceLogout = sessionStorage.getItem('__force_logout__');
|
||||||
const storedAuthProvider = sessionStorage.getItem('auth_provider');
|
|
||||||
|
|
||||||
// Clear all tokens EXCEPT id_token (we need it for provider logout)
|
// Use TokenManager.clearAll() but then restore logout flags
|
||||||
// Note: We'll clear id_token after provider logout
|
|
||||||
// Clear tokens (but we'll restore id_token if needed)
|
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
|
|
||||||
// Restore logout flags and id_token immediately after clearAll
|
// Restore logout flags immediately after clearAll
|
||||||
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
||||||
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
||||||
if (idToken) {
|
|
||||||
TokenManager.setIdToken(idToken); // Restore id_token for provider logout
|
console.log('🚪 Local storage cleared (logout flags preserved)');
|
||||||
}
|
|
||||||
if (storedAuthProvider) {
|
// Final verification BEFORE redirect
|
||||||
sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout
|
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
|
// Small delay to ensure sessionStorage is written before redirect
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Handle provider-specific logout
|
// Redirect directly to login page with flags
|
||||||
if (authProvider === 'tanflow' && idToken) {
|
|
||||||
console.log('🚪 Initiating Tanflow logout...');
|
|
||||||
// Tanflow logout - redirect to Tanflow logout endpoint
|
|
||||||
// This will clear Tanflow session and redirect back to our app
|
|
||||||
try {
|
|
||||||
tanflowLogout(idToken);
|
|
||||||
// tanflowLogout will redirect, so we don't need to do anything else here
|
|
||||||
return;
|
|
||||||
} catch (tanflowLogoutError) {
|
|
||||||
console.error('🚪 Tanflow logout error:', tanflowLogoutError);
|
|
||||||
// Fall through to default logout flow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
|
|
||||||
console.log('🚪 Using OKTA logout flow or fallback');
|
|
||||||
sessionStorage.removeItem('auth_provider');
|
|
||||||
// Clear id_token now since we're not using provider logout
|
|
||||||
if (idToken) {
|
|
||||||
TokenManager.clearAll(); // Clear id_token too
|
|
||||||
}
|
|
||||||
// The okta_logged_out flag will trigger prompt=login in the login() function
|
// The okta_logged_out flag will trigger prompt=login in the login() function
|
||||||
// This forces re-authentication even if Okta session still exists
|
// This forces re-authentication even if Okta session still exists
|
||||||
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
|
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
|
||||||
@ -589,27 +464,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAccessTokenSilently = async (): Promise<string | null> => {
|
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();
|
const token = TokenManager.getAccessToken();
|
||||||
if (token && !isTokenExpired(token)) {
|
if (token && !isTokenExpired(token)) {
|
||||||
return token;
|
return token;
|
||||||
@ -625,20 +479,10 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshTokenSilently = async (): Promise<void> => {
|
const refreshTokenSilently = async (): Promise<void> => {
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newToken = await refreshAccessToken();
|
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) {
|
if (newToken) {
|
||||||
// Token refreshed successfully (development mode)
|
// Token refreshed successfully
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error('Failed to refresh token');
|
throw new Error('Failed to refresh token');
|
||||||
@ -666,10 +510,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth0-based Auth Provider (for production)
|
* 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 (
|
return (
|
||||||
<Auth0Provider
|
<Auth0Provider
|
||||||
domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
|
domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
|
||||||
@ -677,8 +519,11 @@ export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
authorizationParams={{
|
authorizationParams={{
|
||||||
redirect_uri: window.location.origin + '/login/callback',
|
redirect_uri: window.location.origin + '/login/callback',
|
||||||
}}
|
}}
|
||||||
onRedirectCallback={(_appState) => {
|
onRedirectCallback={(appState) => {
|
||||||
// Auth0 redirect callback handled
|
console.log('Auth0 Redirect Callback:', {
|
||||||
|
appState,
|
||||||
|
returnTo: appState?.returnTo || window.location.pathname,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Auth0ContextWrapper>{children}</Auth0ContextWrapper>
|
<Auth0ContextWrapper>{children}</Auth0ContextWrapper>
|
||||||
@ -686,7 +531,6 @@ export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper to convert Auth0 hook to our context format
|
* Wrapper to convert Auth0 hook to our context format
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,172 +0,0 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Filter, Search, SortAsc, SortDesc, Flame, Target, CheckCircle, XCircle, X } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ClosedRequestsFiltersProps {
|
|
||||||
searchTerm: string;
|
|
||||||
priorityFilter: string;
|
|
||||||
statusFilter: string;
|
|
||||||
templateTypeFilter: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
|
||||||
sortOrder: 'asc' | 'desc';
|
|
||||||
activeFiltersCount: number;
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onPriorityChange: (value: string) => void;
|
|
||||||
onStatusChange: (value: string) => void;
|
|
||||||
onTemplateTypeChange: (value: string) => void;
|
|
||||||
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
|
|
||||||
onSortOrderChange: () => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard Closed Requests Filters Component
|
|
||||||
*
|
|
||||||
* Used for regular users (non-dealers).
|
|
||||||
* Includes: Search, Priority, Status (Closure Type), Template Type, and Sort filters.
|
|
||||||
*/
|
|
||||||
export function StandardClosedRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
priorityFilter,
|
|
||||||
statusFilter,
|
|
||||||
// templateTypeFilter,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
activeFiltersCount,
|
|
||||||
onSearchChange,
|
|
||||||
onPriorityChange,
|
|
||||||
onStatusChange,
|
|
||||||
// onTemplateTypeChange,
|
|
||||||
onSortByChange,
|
|
||||||
onSortOrderChange,
|
|
||||||
onClearFilters,
|
|
||||||
}: ClosedRequestsFiltersProps) {
|
|
||||||
return (
|
|
||||||
<Card className="shadow-lg border-0" data-testid="closed-requests-filters">
|
|
||||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
|
|
||||||
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<span className="text-blue-600 font-medium">
|
|
||||||
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
|
||||||
data-testid="closed-requests-clear-filters"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="text-xs sm:text-sm">Clear</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests, IDs..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white transition-colors"
|
|
||||||
data-testid="closed-requests-search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={onPriorityChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-priority-filter">
|
|
||||||
<SelectValue placeholder="All Priorities" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priorities</SelectItem>
|
|
||||||
<SelectItem value="express">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Flame className="w-4 h-4 text-orange-600" />
|
|
||||||
<span>Express</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="standard">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Target className="w-4 h-4 text-blue-600" />
|
|
||||||
<span>Standard</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-status-filter">
|
|
||||||
<SelectValue placeholder="Closure Type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Closures</SelectItem>
|
|
||||||
<SelectItem value="approved">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
||||||
<span>Closed After Approval</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="rejected">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<XCircle className="w-4 h-4 text-red-600" />
|
|
||||||
<span>Closed After Rejection</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{/*
|
|
||||||
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select> */}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">
|
|
||||||
<SelectValue placeholder="Sort by" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="due">Due Date</SelectItem>
|
|
||||||
<SelectItem value="created">Date Created</SelectItem>
|
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onSortOrderChange}
|
|
||||||
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
|
||||||
data-testid="closed-requests-sort-order"
|
|
||||||
>
|
|
||||||
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Filter, Search, SortAsc, SortDesc, X, Flame, Target } from 'lucide-react';
|
|
||||||
|
|
||||||
interface RequestsFiltersProps {
|
|
||||||
searchTerm: string;
|
|
||||||
statusFilter: string;
|
|
||||||
priorityFilter: string;
|
|
||||||
templateTypeFilter: string;
|
|
||||||
sortBy: 'created' | 'due' | 'priority' | 'sla';
|
|
||||||
sortOrder: 'asc' | 'desc';
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onStatusFilterChange: (value: string) => void;
|
|
||||||
onPriorityFilterChange: (value: string) => void;
|
|
||||||
onTemplateTypeFilterChange: (value: string) => void;
|
|
||||||
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
|
|
||||||
onSortOrderChange: (value: 'asc' | 'desc') => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
activeFiltersCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard Requests Filters Component
|
|
||||||
*
|
|
||||||
* Used for regular users (non-dealers).
|
|
||||||
* Includes: Search, Status, Priority, Template Type, and Sort filters.
|
|
||||||
*/
|
|
||||||
export function StandardRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
statusFilter,
|
|
||||||
priorityFilter,
|
|
||||||
// templateTypeFilter,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
onSearchChange,
|
|
||||||
onStatusFilterChange,
|
|
||||||
onPriorityFilterChange,
|
|
||||||
// onTemplateTypeFilterChange,
|
|
||||||
onSortByChange,
|
|
||||||
onSortOrderChange,
|
|
||||||
onClearFilters,
|
|
||||||
activeFiltersCount,
|
|
||||||
}: RequestsFiltersProps) {
|
|
||||||
return (
|
|
||||||
<Card className="shadow-lg border-0">
|
|
||||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
|
|
||||||
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<span className="text-blue-600 font-medium">
|
|
||||||
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="text-xs sm:text-sm">Clear</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
|
||||||
{/* Standard filters - Search, Status, Priority, Template Type, and Sort */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests, IDs..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={onPriorityFilterChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Priorities" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priorities</SelectItem>
|
|
||||||
<SelectItem value="express">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Flame className="w-4 h-4 text-orange-600" />
|
|
||||||
<span>Express</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="standard">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Target className="w-4 h-4 text-blue-600" />
|
|
||||||
<span>Standard</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Statuses" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending (In Approval)</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select> */}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="Sort by" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="due">Due Date</SelectItem>
|
|
||||||
<SelectItem value="created">Date Created</SelectItem>
|
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
|
||||||
<SelectItem value="sla">SLA Progress</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
|
|
||||||
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
|
||||||
>
|
|
||||||
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,457 +0,0 @@
|
|||||||
/**
|
|
||||||
* Standard User All Requests Filters Component
|
|
||||||
*
|
|
||||||
* Full filters for regular users (non-dealers).
|
|
||||||
* Includes: Search, Status, Priority, Template Type, Department, SLA Compliance,
|
|
||||||
* Initiator, Approver, and Date Range filters.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import type { DateRange } from '@/services/dashboard.service';
|
|
||||||
import { CustomDatePicker } from '@/components/ui/date-picker';
|
|
||||||
|
|
||||||
interface StandardUserAllRequestsFiltersProps {
|
|
||||||
// Filters
|
|
||||||
searchTerm: string;
|
|
||||||
statusFilter: string;
|
|
||||||
priorityFilter: string;
|
|
||||||
templateTypeFilter: string;
|
|
||||||
departmentFilter: string;
|
|
||||||
slaComplianceFilter: string;
|
|
||||||
initiatorFilter: string;
|
|
||||||
approverFilter: string;
|
|
||||||
approverFilterType: 'current' | 'any';
|
|
||||||
dateRange: DateRange;
|
|
||||||
customStartDate?: Date;
|
|
||||||
customEndDate?: Date;
|
|
||||||
showCustomDatePicker: boolean;
|
|
||||||
|
|
||||||
// Departments
|
|
||||||
departments: string[];
|
|
||||||
loadingDepartments: boolean;
|
|
||||||
|
|
||||||
// State for user search
|
|
||||||
initiatorSearch: {
|
|
||||||
selectedUser: { userId: string; email: string; displayName?: string } | null;
|
|
||||||
searchQuery: string;
|
|
||||||
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
|
|
||||||
showResults: boolean;
|
|
||||||
handleSearch: (query: string) => void;
|
|
||||||
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
|
|
||||||
handleClear: () => void;
|
|
||||||
setShowResults: (show: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
approverSearch: {
|
|
||||||
selectedUser: { userId: string; email: string; displayName?: string } | null;
|
|
||||||
searchQuery: string;
|
|
||||||
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
|
|
||||||
showResults: boolean;
|
|
||||||
handleSearch: (query: string) => void;
|
|
||||||
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
|
|
||||||
handleClear: () => void;
|
|
||||||
setShowResults: (show: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onStatusChange: (value: string) => void;
|
|
||||||
onPriorityChange: (value: string) => void;
|
|
||||||
onTemplateTypeChange: (value: string) => void;
|
|
||||||
onDepartmentChange: (value: string) => void;
|
|
||||||
onSlaComplianceChange: (value: string) => void;
|
|
||||||
onInitiatorChange?: (value: string) => void;
|
|
||||||
onApproverChange?: (value: string) => void;
|
|
||||||
onApproverTypeChange?: (value: 'current' | 'any') => void;
|
|
||||||
onDateRangeChange: (value: DateRange) => void;
|
|
||||||
onCustomStartDateChange?: (date: Date | undefined) => void;
|
|
||||||
onCustomEndDateChange?: (date: Date | undefined) => void;
|
|
||||||
onShowCustomDatePickerChange?: (show: boolean) => void;
|
|
||||||
onApplyCustomDate?: () => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
hasActiveFilters: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StandardUserAllRequestsFilters({
|
|
||||||
searchTerm,
|
|
||||||
statusFilter,
|
|
||||||
priorityFilter,
|
|
||||||
// templateTypeFilter,
|
|
||||||
departmentFilter,
|
|
||||||
slaComplianceFilter,
|
|
||||||
initiatorFilter: _initiatorFilter,
|
|
||||||
approverFilter,
|
|
||||||
approverFilterType,
|
|
||||||
dateRange,
|
|
||||||
customStartDate,
|
|
||||||
customEndDate,
|
|
||||||
showCustomDatePicker,
|
|
||||||
departments,
|
|
||||||
loadingDepartments,
|
|
||||||
initiatorSearch,
|
|
||||||
approverSearch,
|
|
||||||
onSearchChange,
|
|
||||||
onStatusChange,
|
|
||||||
onPriorityChange,
|
|
||||||
// onTemplateTypeChange,
|
|
||||||
onDepartmentChange,
|
|
||||||
onSlaComplianceChange,
|
|
||||||
onInitiatorChange: _onInitiatorChange,
|
|
||||||
onApproverChange: _onApproverChange,
|
|
||||||
onApproverTypeChange,
|
|
||||||
onDateRangeChange,
|
|
||||||
onCustomStartDateChange,
|
|
||||||
onCustomEndDateChange,
|
|
||||||
onShowCustomDatePickerChange,
|
|
||||||
onApplyCustomDate,
|
|
||||||
onClearFilters,
|
|
||||||
hasActiveFilters,
|
|
||||||
}: StandardUserAllRequestsFiltersProps) {
|
|
||||||
return (
|
|
||||||
<Card className="border-gray-200 shadow-md" data-testid="user-all-requests-filters">
|
|
||||||
<CardContent className="p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="w-5 h-5 text-muted-foreground" />
|
|
||||||
<h3 className="font-semibold text-gray-900">Advanced Filters</h3>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Primary Filters */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
|
||||||
<div className="relative md:col-span-3 lg:col-span-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-10 h-10"
|
|
||||||
data-testid="search-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="status-filter">
|
|
||||||
<SelectValue placeholder="All Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="paused">Paused</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={onPriorityChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="priority-filter">
|
|
||||||
<SelectValue placeholder="All Priority" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priority</SelectItem>
|
|
||||||
<SelectItem value="express">Express</SelectItem>
|
|
||||||
<SelectItem value="standard">Standard</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Custom</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select> */}
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={departmentFilter}
|
|
||||||
onValueChange={onDepartmentChange}
|
|
||||||
disabled={loadingDepartments || departments.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10" data-testid="department-filter">
|
|
||||||
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Departments</SelectItem>
|
|
||||||
{departments.map((dept) => (
|
|
||||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={slaComplianceFilter} onValueChange={onSlaComplianceChange}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
|
|
||||||
<SelectValue placeholder="All SLA Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All SLA Status</SelectItem>
|
|
||||||
<SelectItem value="compliant">Compliant</SelectItem>
|
|
||||||
<SelectItem value="on-track">On Track</SelectItem>
|
|
||||||
<SelectItem value="approaching">Approaching</SelectItem>
|
|
||||||
<SelectItem value="critical">Critical</SelectItem>
|
|
||||||
<SelectItem value="breached">Breached</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Filters - Initiator and Approver */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
|
||||||
{/* Initiator Filter */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
|
|
||||||
<div className="relative">
|
|
||||||
{initiatorSearch.selectedUser ? (
|
|
||||||
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
|
|
||||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
|
||||||
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="Search initiator..."
|
|
||||||
value={initiatorSearch.searchQuery}
|
|
||||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (initiatorSearch.searchResults.length > 0) {
|
|
||||||
initiatorSearch.setShowResults(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
|
|
||||||
className="h-10"
|
|
||||||
data-testid="initiator-search-input"
|
|
||||||
/>
|
|
||||||
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{initiatorSearch.searchResults.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => initiatorSearch.handleSelect(user)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{user.displayName || user.email}
|
|
||||||
</span>
|
|
||||||
{user.displayName && (
|
|
||||||
<span className="text-xs text-gray-500">{user.email}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Approver Filter */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Label className="text-sm font-medium text-gray-700">Approver</Label>
|
|
||||||
{approverFilter !== 'all' && onApproverTypeChange && (
|
|
||||||
<Select
|
|
||||||
value={approverFilterType}
|
|
||||||
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-32 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="current">Current Only</SelectItem>
|
|
||||||
<SelectItem value="any">Any Approver</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
{approverSearch.selectedUser ? (
|
|
||||||
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
|
|
||||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
|
||||||
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="Search approver..."
|
|
||||||
value={approverSearch.searchQuery}
|
|
||||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (approverSearch.searchResults.length > 0) {
|
|
||||||
approverSearch.setShowResults(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
|
|
||||||
className="h-10"
|
|
||||||
data-testid="approver-search-input"
|
|
||||||
/>
|
|
||||||
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{approverSearch.searchResults.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => approverSearch.handleSelect(user)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{user.displayName || user.email}
|
|
||||||
</span>
|
|
||||||
{user.displayName && (
|
|
||||||
<span className="text-xs text-gray-500">{user.email}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Range Filter */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
|
|
||||||
<SelectTrigger className="w-[160px] h-10">
|
|
||||||
<SelectValue placeholder="Date Range" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Time</SelectItem>
|
|
||||||
<SelectItem value="today">Today</SelectItem>
|
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
|
||||||
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
|
||||||
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
|
||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{dateRange === 'custom' && (
|
|
||||||
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
{customStartDate && customEndDate
|
|
||||||
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
|
|
||||||
: 'Select dates'}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-4" align="start">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="start-date">Start Date</Label>
|
|
||||||
<CustomDatePicker
|
|
||||||
value={customStartDate || null}
|
|
||||||
onChange={(dateStr: string | null) => {
|
|
||||||
const date = dateStr ? new Date(dateStr) : undefined;
|
|
||||||
if (date) {
|
|
||||||
onCustomStartDateChange?.(date);
|
|
||||||
if (customEndDate && date > customEndDate) {
|
|
||||||
onCustomEndDateChange?.(date);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onCustomStartDateChange?.(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maxDate={new Date()}
|
|
||||||
placeholderText="dd/mm/yyyy"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="end-date">End Date</Label>
|
|
||||||
<CustomDatePicker
|
|
||||||
value={customEndDate || null}
|
|
||||||
onChange={(dateStr: string | null) => {
|
|
||||||
const date = dateStr ? new Date(dateStr) : undefined;
|
|
||||||
if (date) {
|
|
||||||
onCustomEndDateChange?.(date);
|
|
||||||
if (customStartDate && date < customStartDate) {
|
|
||||||
onCustomStartDateChange?.(date);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onCustomEndDateChange?.(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
minDate={customStartDate || undefined}
|
|
||||||
maxDate={new Date()}
|
|
||||||
placeholderText="dd/mm/yyyy"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-2 border-t">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={onApplyCustomDate}
|
|
||||||
disabled={!customStartDate || !customEndDate}
|
|
||||||
className="flex-1 bg-re-green hover:bg-re-green/90"
|
|
||||||
>
|
|
||||||
Apply Range
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
onShowCustomDatePickerChange?.(false);
|
|
||||||
onCustomStartDateChange?.(undefined);
|
|
||||||
onCustomEndDateChange?.(undefined);
|
|
||||||
onDateRangeChange('month');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom Request Creation Component
|
|
||||||
*
|
|
||||||
* This component handles the creation of custom requests.
|
|
||||||
* Located in: src/custom/components/request-creation/
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Re-export the original component
|
|
||||||
export { CreateRequest } from '@/pages/CreateRequest/CreateRequest';
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom Request Overview Tab
|
|
||||||
*
|
|
||||||
* This component is specific to Custom requests.
|
|
||||||
* Located in: src/custom/components/request-detail/
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Re-export the original component
|
|
||||||
export { OverviewTab } from '@/pages/RequestDetail/components/tabs/OverviewTab';
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom Request Workflow Tab
|
|
||||||
*
|
|
||||||
* This component is specific to Custom requests.
|
|
||||||
* Located in: src/custom/components/request-detail/
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Re-export the original component
|
|
||||||
export { WorkflowTab } from '@/pages/RequestDetail/components/tabs/WorkflowTab';
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user