export enhanced code cleaning and with some new bug fixes
This commit is contained in:
parent
00f0b786f6
commit
99a59ac05b
411
README.md
411
README.md
@ -4,43 +4,92 @@ A modern, enterprise-grade approval and request management system built with Rea
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Development](#development)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Available Scripts](#available-scripts)
|
||||
- [Configuration](#configuration)
|
||||
- [Contributing](#contributing)
|
||||
- [Features](#-features)
|
||||
- [Tech Stack](#️-tech-stack)
|
||||
- [Prerequisites](#-prerequisites)
|
||||
- [Installation](#-installation)
|
||||
- [Development](#-development)
|
||||
- [Project Structure](#-project-structure)
|
||||
- [Available Scripts](#-available-scripts)
|
||||
- [Configuration](#️-configuration)
|
||||
- [Key Features Deep Dive](#-key-features-deep-dive)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
- [Contributing](#-contributing)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🔄 Dual Workflow System**
|
||||
- Custom Request Workflow with user-defined approvers
|
||||
- Claim Management Workflow (8-step predefined process)
|
||||
|
||||
- **📊 Comprehensive Dashboard**
|
||||
- Real-time statistics and metrics
|
||||
- High-priority alerts
|
||||
- Recent activity tracking
|
||||
|
||||
- **🎯 Request Management**
|
||||
- Create, track, and manage approval requests
|
||||
- Document upload and management
|
||||
- Work notes and audit trails
|
||||
- Spectator and stakeholder management
|
||||
|
||||
- **🎨 Modern UI/UX**
|
||||
- Responsive design (mobile, tablet, desktop)
|
||||
- Dark mode support
|
||||
- Accessible components (WCAG compliant)
|
||||
- Royal Enfield brand theming
|
||||
### 🔄 Dual Workflow System
|
||||
- **Custom Request Workflow** - User-defined approvers, spectators, and workflow steps
|
||||
- **Claim Management Workflow** - 8-step predefined process for dealer claim management
|
||||
- Flexible approval chains with multi-level approvers
|
||||
- TAT (Turnaround Time) tracking at each approval level
|
||||
|
||||
- **🔔 Notifications**
|
||||
- Real-time toast notifications
|
||||
- SLA tracking and reminders
|
||||
- Approval status updates
|
||||
### 📊 Comprehensive Dashboard
|
||||
- Real-time statistics and metrics
|
||||
- High-priority alerts and critical request tracking
|
||||
- Recent activity feed with pagination
|
||||
- Upcoming deadlines and SLA breach warnings
|
||||
- Department-wise performance metrics
|
||||
- Customizable KPI widgets (Admin only)
|
||||
|
||||
### 🎯 Request Management
|
||||
- Create, track, and manage approval requests
|
||||
- Document upload and management with file type validation
|
||||
- Work notes and comprehensive audit trails
|
||||
- Spectator and stakeholder management
|
||||
- Request filtering, search, and export capabilities
|
||||
- Detailed request lifecycle tracking
|
||||
|
||||
### 👥 Admin Control Panel
|
||||
- **User Management** - Search, assign roles (USER, MANAGEMENT, ADMIN), and manage user permissions
|
||||
- **User Role Management** - Assign and manage user roles with Okta integration
|
||||
- **System Configuration** - Comprehensive admin settings:
|
||||
- **KPI Configuration** - Configure dashboard KPIs, visibility, and thresholds
|
||||
- **Analytics Configuration** - Data retention, export formats, and analytics features
|
||||
- **TAT Configuration** - Working hours, priority-based TAT, escalation settings
|
||||
- **Notification Configuration** - Email templates, notification channels, and settings
|
||||
- **Notification Preferences** - User-configurable notification settings
|
||||
- **Document Configuration** - File type restrictions, size limits, upload policies
|
||||
- **Dashboard Configuration** - Customize dashboard layout and widgets per role
|
||||
- **AI Configuration** - AI provider settings, parameters, and features
|
||||
- **Sharing Configuration** - Sharing policies and permissions
|
||||
- **Holiday Management** - Configure business holidays for SLA calculations
|
||||
|
||||
### 📈 Approver Performance Analytics
|
||||
- Detailed approver performance metrics and statistics
|
||||
- Request approval history and trends
|
||||
- Average approval time analysis
|
||||
- Approval rate and efficiency metrics
|
||||
- TAT compliance tracking per approver
|
||||
- Performance comparison and benchmarking
|
||||
- Export capabilities for performance reports
|
||||
|
||||
### 💬 Real-Time Live Chat (Work Notes)
|
||||
- **WebSocket Integration** - Real-time bidirectional communication
|
||||
- **Live Work Notes** - Instant messaging within request context
|
||||
- **Presence Indicators** - See who's online/offline in real-time
|
||||
- **Mention System** - @mention participants for notifications
|
||||
- **File Attachments** - Share documents directly in chat
|
||||
- **Message History** - Persistent chat history per request
|
||||
- **Auto-reconnection** - Automatic reconnection on network issues
|
||||
- **Room-based Communication** - Isolated chat rooms per request
|
||||
|
||||
### 🔔 Advanced Notifications
|
||||
- **Web Push Notifications** - Browser push notifications using VAPID
|
||||
- **Service Worker Integration** - Background notification delivery
|
||||
- **Real-time Toast Notifications** - In-app notification system
|
||||
- **SLA Tracking & Reminders** - Automated TAT breach alerts
|
||||
- **Approval Status Updates** - Real-time status change notifications
|
||||
- **Email Notifications** - Configurable email notification channels
|
||||
- **Notification Preferences** - User-configurable notification settings
|
||||
|
||||
### 🎨 Modern UI/UX
|
||||
- Responsive design (mobile, tablet, desktop)
|
||||
- Dark mode support
|
||||
- Accessible components (WCAG compliant)
|
||||
- Royal Enfield brand theming
|
||||
- Smooth animations and transitions
|
||||
- Intuitive navigation and user flows
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
@ -50,8 +99,11 @@ A modern, enterprise-grade approval and request management system built with Rea
|
||||
- **Styling:** Tailwind CSS 3.4+
|
||||
- **UI Components:** shadcn/ui + Radix UI
|
||||
- **Icons:** Lucide React
|
||||
- **Notifications:** Sonner
|
||||
- **State Management:** React Hooks (useState, useMemo)
|
||||
- **Notifications:** Sonner (Toast) + Web Push API (VAPID)
|
||||
- **Real-Time Communication:** Socket.IO Client
|
||||
- **State Management:** React Hooks (useState, useMemo, useContext)
|
||||
- **Authentication:** Okta SSO Integration
|
||||
- **HTTP Client:** Axios
|
||||
|
||||
## 📦 Prerequisites
|
||||
|
||||
@ -74,7 +126,7 @@ A modern, enterprise-grade approval and request management system built with Rea
|
||||
|
||||
\`\`\`bash
|
||||
git clone <repository-url>
|
||||
cd Re_Figma_Code
|
||||
cd Re_Frontend_Code
|
||||
\`\`\`
|
||||
|
||||
### 2. Install dependencies
|
||||
@ -147,11 +199,20 @@ VITE_PUBLIC_VAPID_KEY=your-production-vapid-key
|
||||
| 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 direct file access (without `/api/v1`) | Yes | `http://localhost:5000` |
|
||||
| `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
|
||||
@ -213,28 +274,57 @@ Re_Figma_Code/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # Reusable UI components (40+)
|
||||
│ │ ├── admin/ # Admin components
|
||||
│ │ │ ├── AIConfig/ # AI configuration
|
||||
│ │ │ ├── AnalyticsConfig/ # Analytics settings
|
||||
│ │ │ ├── DashboardConfig/ # Dashboard customization
|
||||
│ │ │ ├── DocumentConfig/ # Document policies
|
||||
│ │ │ ├── NotificationConfig/ # Notification settings
|
||||
│ │ │ ├── SharingConfig/ # Sharing policies
|
||||
│ │ │ ├── TATConfig/ # TAT configuration
|
||||
│ │ │ ├── UserManagement/ # User management
|
||||
│ │ │ └── UserRoleManager/ # Role assignment
|
||||
│ │ ├── approval/ # Approval workflow components
|
||||
│ │ ├── common/ # Common reusable components
|
||||
│ │ ├── dashboard/ # Dashboard widgets
|
||||
│ │ ├── modals/ # Modal components
|
||||
│ │ ├── figma/ # Figma-specific components
|
||||
│ │ ├── Dashboard.tsx
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── ClaimManagementWizard.tsx
|
||||
│ │ ├── NewRequestWizard.tsx
|
||||
│ │ ├── RequestDetail.tsx
|
||||
│ │ ├── ClaimManagementDetail.tsx
|
||||
│ │ ├── MyRequests.tsx
|
||||
│ │ ├── participant/ # Participant management
|
||||
│ │ ├── workflow/ # Workflow components
|
||||
│ │ └── workNote/ # Work notes/chat components
|
||||
│ ├── pages/
|
||||
│ │ ├── Admin/ # Admin control panel
|
||||
│ │ ├── ApproverPerformance/ # Approver analytics
|
||||
│ │ ├── Auth/ # Authentication pages
|
||||
│ │ ├── Dashboard/ # Main dashboard
|
||||
│ │ ├── RequestDetail/ # Request detail view
|
||||
│ │ ├── Requests/ # Request listing
|
||||
│ │ └── ...
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── useRequestSocket.ts # WebSocket integration
|
||||
│ │ ├── useDocumentUpload.ts # Document management
|
||||
│ │ ├── useSLATracking.ts # SLA tracking
|
||||
│ │ └── ...
|
||||
│ ├── services/ # API services
|
||||
│ │ ├── adminApi.ts # Admin API calls
|
||||
│ │ ├── authApi.ts # Authentication API
|
||||
│ │ ├── workflowApi.ts # Workflow API
|
||||
│ │ └── ...
|
||||
│ ├── utils/
|
||||
│ │ ├── customRequestDatabase.ts
|
||||
│ │ ├── claimManagementDatabase.ts
|
||||
│ │ └── dealerDatabase.ts
|
||||
│ │ ├── socket.ts # Socket.IO utilities
|
||||
│ │ ├── pushNotifications.ts # Web push notifications
|
||||
│ │ ├── slaTracker.ts # SLA calculation utilities
|
||||
│ │ └── ...
|
||||
│ ├── contexts/
|
||||
│ │ └── AuthContext.tsx # Authentication context
|
||||
│ ├── styles/
|
||||
│ │ └── globals.css
|
||||
│ ├── types/
|
||||
│ │ └── index.ts # TypeScript type definitions
|
||||
│ │ └── index.ts
|
||||
│ ├── App.tsx
|
||||
│ └── main.tsx
|
||||
├── public/ # Static assets
|
||||
├── .vscode/ # VS Code settings
|
||||
├── public/
|
||||
│ └── service-worker.js # Service worker for push notifications
|
||||
├── .vscode/
|
||||
├── index.html
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
@ -267,7 +357,7 @@ The project uses path aliases for cleaner imports:
|
||||
|
||||
\`\`\`typescript
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getDealerInfo } from '@/utils/dealerDatabase';
|
||||
import { getSocket } from '@/utils/socket';
|
||||
\`\`\`
|
||||
|
||||
Path aliases are configured in:
|
||||
@ -311,6 +401,7 @@ To connect to the backend API:
|
||||
1. **Update API base URL** in `.env.local`:
|
||||
\`\`\`env
|
||||
VITE_API_BASE_URL=http://localhost:5000/api/v1
|
||||
VITE_BASE_URL=http://localhost:5000
|
||||
\`\`\`
|
||||
|
||||
2. **Configure CORS** in your backend to allow your frontend origin
|
||||
@ -318,10 +409,24 @@ To connect to the backend API:
|
||||
3. **Authentication:**
|
||||
- Configure Okta credentials in environment variables
|
||||
- Ensure backend validates JWT tokens from Okta
|
||||
- Backend should handle token exchange and refresh
|
||||
|
||||
4. **API Services:**
|
||||
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
|
||||
|
||||
@ -329,10 +434,181 @@ To connect to the backend API:
|
||||
- **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
|
||||
# Test WebSocket connection
|
||||
# Open browser console and check for Socket.IO connection logs
|
||||
\`\`\`
|
||||
|
||||
#### Web Push Notifications Not Working
|
||||
|
||||
1. Ensure `VITE_PUBLIC_VAPID_KEY` is set in `.env.local`
|
||||
2. Verify service worker is registered (check Application tab in DevTools)
|
||||
3. Check browser notification permissions
|
||||
4. Ensure HTTPS in production (required for push notifications)
|
||||
5. Verify backend push notification endpoint is configured
|
||||
|
||||
\`\`\`bash
|
||||
# Check service worker registration
|
||||
# Open DevTools > Application > Service Workers
|
||||
\`\`\`
|
||||
|
||||
#### Port Already in Use
|
||||
|
||||
If the default port (5173) is in use:
|
||||
@ -381,6 +657,35 @@ npm run type-check
|
||||
- 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)
|
||||
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
# 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
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
# 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
|
||||
@ -213,7 +213,6 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
// Add to dynamic requests
|
||||
setDynamicRequests([...dynamicRequests, newCustomRequest]);
|
||||
|
||||
console.log('New custom request created:', newCustomRequest);
|
||||
navigate('/my-requests');
|
||||
toast.success('Request Submitted Successfully!', {
|
||||
description: `Your request "${requestData.title}" (${requestId}) has been created and sent for approval.`,
|
||||
@ -221,7 +220,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleApprovalSubmit = (action: 'approve' | 'reject', comment: string) => {
|
||||
const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (action === 'approve') {
|
||||
@ -236,7 +235,6 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`${action} action completed with comment:`, comment);
|
||||
setApprovalAction(null);
|
||||
resolve(true);
|
||||
}, 1000);
|
||||
@ -658,8 +656,6 @@ interface MainAppProps {
|
||||
|
||||
export default function App(props?: MainAppProps) {
|
||||
const { onLogout } = props || {};
|
||||
console.log('🟢 Main App component rendered');
|
||||
console.log('🟢 onLogout prop received:', !!onLogout);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
||||
@ -14,14 +14,7 @@ export function AuthDebugInfo({ isOpen, onClose }: AuthDebugInfoProps) {
|
||||
const { user, isAuthenticated, isLoading, error } = useAuth0();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('AuthDebugInfo - Current Auth State:', {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
hasUser: !!user,
|
||||
error: error?.message,
|
||||
userData: user,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
// Auth state debug info - removed console.log
|
||||
}, [user, isAuthenticated, isLoading, error]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@ -32,7 +32,6 @@ export function AnalyticsConfig() {
|
||||
|
||||
const handleSave = () => {
|
||||
// TODO: Implement API call to save configuration
|
||||
console.log('Saving analytics configuration:', config);
|
||||
toast.success('Analytics configuration saved successfully');
|
||||
};
|
||||
|
||||
|
||||
@ -258,10 +258,12 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out notification rules and dashboard layout categories
|
||||
// Filter out notification rules, dashboard layout categories, and allow external sharing
|
||||
const excludedCategories = ['NOTIFICATION_RULES', 'DASHBOARD_LAYOUT'];
|
||||
const excludedConfigKeys = ['ALLOW_EXTERNAL_SHARING'];
|
||||
const filteredConfigurations = configurations.filter(
|
||||
config => !excludedCategories.includes(config.configCategory)
|
||||
config => !excludedCategories.includes(config.configCategory) &&
|
||||
!excludedConfigKeys.includes(config.configKey)
|
||||
);
|
||||
|
||||
const groupedConfigs = filteredConfigurations.reduce((acc, config) => {
|
||||
|
||||
@ -60,7 +60,6 @@ export function DashboardConfig() {
|
||||
|
||||
const handleSave = () => {
|
||||
// TODO: Implement API call to save dashboard configuration
|
||||
console.log('Saving dashboard configuration:', config);
|
||||
toast.success('Dashboard layout saved successfully');
|
||||
};
|
||||
|
||||
|
||||
@ -88,6 +88,17 @@ export function HolidayManager() {
|
||||
setShowAddDialog(true);
|
||||
};
|
||||
|
||||
// Calculate minimum date (tomorrow for new holidays, no restriction for editing)
|
||||
const getMinDate = () => {
|
||||
// Only enforce minimum date when adding new holidays, not when editing existing ones
|
||||
if (editingHoliday) {
|
||||
return undefined; // Allow editing past holidays
|
||||
}
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return tomorrow.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
@ -357,9 +368,12 @@ export function HolidayManager() {
|
||||
type="date"
|
||||
value={formData.holidayDate}
|
||||
onChange={(e) => setFormData({ ...formData, holidayDate: e.target.value })}
|
||||
min={getMinDate()}
|
||||
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Select the holiday date</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{editingHoliday ? 'Select the holiday date' : 'Select the holiday date (minimum: tomorrow)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Holiday Name Field */}
|
||||
|
||||
@ -29,7 +29,6 @@ export function NotificationConfig() {
|
||||
|
||||
const handleSave = () => {
|
||||
// TODO: Implement API call to save notification configuration
|
||||
console.log('Saving notification configuration:', config);
|
||||
toast.success('Notification configuration saved successfully');
|
||||
};
|
||||
|
||||
|
||||
@ -24,7 +24,6 @@ export function SharingConfig() {
|
||||
|
||||
const handleSave = () => {
|
||||
// TODO: Implement API call to save sharing configuration
|
||||
console.log('Saving sharing configuration:', config);
|
||||
toast.success('Sharing policy saved successfully');
|
||||
};
|
||||
|
||||
|
||||
@ -107,13 +107,10 @@ export function UserRoleManager() {
|
||||
setSearching(true);
|
||||
try {
|
||||
const response = await userApi.searchUsers(query, 20);
|
||||
console.log('Search response:', response);
|
||||
console.log('Response.data:', response.data);
|
||||
|
||||
// Backend returns { success: true, data: [...users], message, timestamp }
|
||||
// Axios response is in response.data, actual user array is in response.data.data
|
||||
const users = response.data?.data || [];
|
||||
console.log('Parsed users:', users);
|
||||
|
||||
setSearchResults(users);
|
||||
} catch (error: any) {
|
||||
@ -218,17 +215,11 @@ export function UserRoleManager() {
|
||||
setLoadingUsers(true);
|
||||
try {
|
||||
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
||||
|
||||
console.log('Users response:', response);
|
||||
|
||||
// Backend returns { success: true, data: { users: [...], pagination: {...}, summary: {...} } }
|
||||
const usersData = response.data?.data?.users || [];
|
||||
const paginationData = response.data?.data?.pagination;
|
||||
const summaryData = response.data?.data?.summary;
|
||||
|
||||
console.log('Parsed users:', usersData);
|
||||
console.log('Pagination:', paginationData);
|
||||
console.log('Summary:', summaryData);
|
||||
|
||||
setUsers(usersData);
|
||||
|
||||
@ -257,11 +248,9 @@ export function UserRoleManager() {
|
||||
const fetchRoleStatistics = async () => {
|
||||
try {
|
||||
const response = await userApi.getRoleStatistics();
|
||||
console.log('Role statistics response:', response);
|
||||
|
||||
// Handle different response formats
|
||||
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
||||
console.log('Statistics data:', statsData);
|
||||
|
||||
setRoleStats({
|
||||
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
|
||||
|
||||
@ -121,7 +121,7 @@ export function Pagination({
|
||||
variant={pageNum === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
|
||||
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
|
||||
data-testid={`${testIdPrefix}-page-${pageNum}`}
|
||||
aria-current={pageNum === currentPage ? 'page' : undefined}
|
||||
>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { LucideIcon, Info } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface KPICardProps {
|
||||
@ -12,6 +12,8 @@ interface KPICardProps {
|
||||
children?: ReactNode;
|
||||
testId?: string;
|
||||
onClick?: () => void;
|
||||
onJustifyClick?: () => void;
|
||||
showJustifyButton?: boolean;
|
||||
}
|
||||
|
||||
export function KPICard({
|
||||
@ -23,8 +25,15 @@ export function KPICard({
|
||||
subtitle,
|
||||
children,
|
||||
testId = 'kpi-card',
|
||||
onClick
|
||||
onClick,
|
||||
onJustifyClick,
|
||||
showJustifyButton = false
|
||||
}: KPICardProps) {
|
||||
const handleJustifyClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent card onClick from firing
|
||||
onJustifyClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="hover:shadow-lg transition-all duration-200 shadow-md cursor-pointer h-full flex flex-col"
|
||||
@ -38,11 +47,25 @@ export function KPICard({
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className={`p-1.5 sm:p-2 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
|
||||
<Icon
|
||||
className={`h-4 w-4 sm:h-4 sm:w-4 ${iconColor}`}
|
||||
data-testid={`${testId}-icon`}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{showJustifyButton && onJustifyClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJustifyClick}
|
||||
className="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
data-testid={`${testId}-justify-button`}
|
||||
title="View detailed breakdown of numbers"
|
||||
aria-label="View detailed breakdown"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className={`p-1.5 sm:p-2 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
|
||||
<Icon
|
||||
className={`h-4 w-4 sm:h-4 sm:w-4 ${iconColor}`}
|
||||
data-testid={`${testId}-icon`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col flex-1 py-3">
|
||||
|
||||
@ -112,7 +112,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
||||
|
||||
// Navigate to request detail page
|
||||
onNavigate(navigationUrl);
|
||||
console.log('[PageLayout] Navigating to:', navigationUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,8 +147,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
||||
const notifs = result.data?.notifications || [];
|
||||
setNotifications(notifs);
|
||||
setUnreadCount(result.data?.unreadCount || 0);
|
||||
|
||||
console.log('[PageLayout] Loaded', notifs.length, 'recent notifications,', result.data?.unreadCount, 'unread');
|
||||
} catch (error) {
|
||||
console.error('[PageLayout] Failed to fetch notifications:', error);
|
||||
}
|
||||
@ -166,7 +163,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
||||
|
||||
// Listen for new notifications
|
||||
const handleNewNotification = (data: { notification: Notification }) => {
|
||||
console.log('[PageLayout] 🔔 New notification received:', data);
|
||||
if (!mounted) return;
|
||||
|
||||
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
|
||||
@ -451,14 +447,10 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
console.log('🔴 Logout button clicked in PageLayout');
|
||||
console.log('🔴 onLogout function exists?', !!onLogout);
|
||||
setShowLogoutDialog(false);
|
||||
if (onLogout) {
|
||||
console.log('🔴 Calling onLogout function...');
|
||||
try {
|
||||
await onLogout();
|
||||
console.log('🔴 onLogout completed');
|
||||
} catch (error) {
|
||||
console.error('🔴 Error calling onLogout:', error);
|
||||
}
|
||||
|
||||
@ -221,8 +221,6 @@ export function AddApproverModal({
|
||||
secondEmail: foundUser.secondEmail,
|
||||
location: foundUser.location
|
||||
});
|
||||
|
||||
console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to validate approver:', error);
|
||||
setValidationModal({
|
||||
@ -358,7 +356,6 @@ export function AddApproverModal({
|
||||
setSelectedUser(user); // Track that user was selected via @ search
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure user exists:', error);
|
||||
setValidationModal({
|
||||
|
||||
@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
|
||||
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||
|
||||
interface AddSpectatorModalProps {
|
||||
@ -70,8 +70,8 @@ export function AddSpectatorModal({
|
||||
useEffect(() => {
|
||||
const loadSystemPolicy = async () => {
|
||||
try {
|
||||
const workflowConfigs = await getAllConfigurations('WORKFLOW_SHARING');
|
||||
const tatConfigs = await getAllConfigurations('TAT_SETTINGS');
|
||||
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) => {
|
||||
@ -250,8 +250,6 @@ export function AddSpectatorModal({
|
||||
secondEmail: foundUser.secondEmail,
|
||||
location: foundUser.location
|
||||
});
|
||||
|
||||
console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to validate spectator:', error);
|
||||
setValidationModal({
|
||||
@ -373,7 +371,6 @@ export function AddSpectatorModal({
|
||||
setSelectedUser(user); // Track that user was selected via @ search
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure user exists:', error);
|
||||
setValidationModal({
|
||||
|
||||
@ -2,6 +2,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
|
||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
|
||||
export interface SLAData {
|
||||
status: 'normal' | 'approaching' | 'critical' | 'breached';
|
||||
@ -90,7 +91,7 @@ export function SLAProgressBar({
|
||||
|
||||
{sla.deadline && (
|
||||
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
|
||||
Due: {new Date(sla.deadline).toLocaleString()} • {sla.percentageUsed || 0}% elapsed
|
||||
Due: {formatDateDDMMYYYY(sla.deadline, true)} • {sla.percentageUsed || 0}% elapsed
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
|
||||
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { toast } from 'sonner';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||
@ -78,6 +78,7 @@ interface WorkNoteChatProps {
|
||||
requestTitle?: string; // Optional title for display
|
||||
onAttachmentsExtracted?: (attachments: any[]) => void; // Callback to pass attachments to parent
|
||||
isInitiator?: boolean; // Whether current user is the initiator
|
||||
isSpectator?: boolean; // Whether current user is a spectator (view-only)
|
||||
currentLevels?: any[]; // Current approval levels for add approver modal
|
||||
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
|
||||
}
|
||||
@ -140,7 +141,7 @@ const FileIcon = ({ type }: { type: string }) => {
|
||||
return <Paperclip className={`${iconClass} text-gray-600`} />;
|
||||
};
|
||||
|
||||
export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, currentLevels = [], onAddApprover }: WorkNoteChatProps) {
|
||||
export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover }: WorkNoteChatProps) {
|
||||
const routeParams = useParams<{ requestId: string }>();
|
||||
const effectiveRequestId = requestId || routeParams.requestId || '';
|
||||
const [message, setMessage] = useState('');
|
||||
@ -193,6 +194,24 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Determine if current user is a spectator (when not passed as prop)
|
||||
const effectiveIsSpectator = useMemo(() => {
|
||||
// If isSpectator is explicitly passed as prop, use it
|
||||
if (isSpectator !== undefined) {
|
||||
return isSpectator;
|
||||
}
|
||||
// Otherwise, determine from participants list
|
||||
if (!currentUserId || participants.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return participants.some((p: any) => {
|
||||
const pUserId = (p as any).userId || (p as any).user_id;
|
||||
const pRole = (p.role || '').toString().toUpperCase();
|
||||
const pType = ((p as any).participantType || (p as any).participant_type || '').toString().toUpperCase();
|
||||
return pUserId === currentUserId && (pRole === 'SPECTATOR' || pType === 'SPECTATOR');
|
||||
});
|
||||
}, [isSpectator, currentUserId, participants]);
|
||||
|
||||
// Log when participants change - logging removed for performance
|
||||
useEffect(() => {
|
||||
// Participants state changed - logging removed
|
||||
@ -230,7 +249,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
};
|
||||
}) : [];
|
||||
setMessages(mapped as any);
|
||||
console.log(`[WorkNoteChat] Loaded ${mapped.length} messages from backend`);
|
||||
} catch (error) {
|
||||
console.error('[WorkNoteChat] Failed to load messages:', error);
|
||||
}
|
||||
@ -313,23 +331,19 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
useEffect(() => {
|
||||
// Skip if participants are already loaded (prevents resetting on tab switch)
|
||||
if (participantsLoadedRef.current) {
|
||||
console.log('[WorkNoteChat] Participants already loaded, skipping reload');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!effectiveRequestId) {
|
||||
console.log('[WorkNoteChat] No requestId, skipping participants load');
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log('[WorkNoteChat] Fetching participants from backend...');
|
||||
const details = await getWorkflowDetails(effectiveRequestId);
|
||||
const rows = Array.isArray(details?.participants) ? details.participants : [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('[WorkNoteChat] No participants found in backend response');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -384,7 +398,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
return () => {
|
||||
// Don't reset on unmount, only on request change
|
||||
if (effectiveRequestId) {
|
||||
console.log('[WorkNoteChat] Request changed, will reload participants on next mount');
|
||||
participantsLoadedRef.current = false;
|
||||
}
|
||||
};
|
||||
@ -408,7 +421,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
useEffect(() => {
|
||||
const loadDocumentPolicy = async () => {
|
||||
try {
|
||||
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||
const configs = await getPublicConfigurations('DOCUMENT_POLICY');
|
||||
const configMap: Record<string, string> = {};
|
||||
configs.forEach((c: AdminConfiguration) => {
|
||||
configMap[c.configKey] = c.configValue;
|
||||
@ -449,53 +462,37 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
|
||||
// Only join room if not skipped (standalone mode)
|
||||
if (!skipSocketJoin) {
|
||||
console.log('[WorkNoteChat] 🚪 About to join request room - requestId:', joinedId, 'userId:', currentUserId, 'socketId:', s.id);
|
||||
joinRequestRoom(s, joinedId, currentUserId);
|
||||
console.log('[WorkNoteChat] ✅ Emitted join:request event (standalone mode)');
|
||||
|
||||
// Mark self as online immediately after joining room
|
||||
setParticipants(prev => {
|
||||
const updated = prev.map(p =>
|
||||
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
|
||||
);
|
||||
const selfParticipant = prev.find(p => (p as any).userId === currentUserId);
|
||||
if (selfParticipant) {
|
||||
console.log('[WorkNoteChat] 🟢 Marked self as online:', selfParticipant.name);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
console.log('[WorkNoteChat] ⏭️ Skipping socket join - parent component handling connection');
|
||||
|
||||
// Still mark self as online even when embedded (parent handles socket but we track presence)
|
||||
setParticipants(prev => {
|
||||
const updated = prev.map(p =>
|
||||
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
|
||||
);
|
||||
const selfParticipant = prev.find(p => (p as any).userId === currentUserId);
|
||||
if (selfParticipant) {
|
||||
console.log('[WorkNoteChat] 🟢 Marked self as online (embedded mode):', selfParticipant.name);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle new work notes
|
||||
const noteHandler = (payload: any) => {
|
||||
console.log('[WorkNoteChat] 📨 Received worknote:new event:', payload);
|
||||
const n = payload?.note || payload;
|
||||
if (!n) {
|
||||
console.log('[WorkNoteChat] ⚠️ No note data in payload');
|
||||
return;
|
||||
}
|
||||
|
||||
const noteId = n.noteId || n.id;
|
||||
console.log('[WorkNoteChat] Processing note:', noteId, 'from:', n.userName || n.user_name);
|
||||
|
||||
// Prevent duplicates: check if message with same noteId already exists
|
||||
setMessages(prev => {
|
||||
if (prev.some(m => m.id === noteId)) {
|
||||
console.log('[WorkNoteChat] ⏭️ Duplicate note, skipping:', noteId);
|
||||
return prev; // Already exists, don't add
|
||||
}
|
||||
|
||||
@ -525,66 +522,53 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
})) : undefined
|
||||
} as any;
|
||||
|
||||
console.log('[WorkNoteChat] ✅ Adding new message to state:', newMessage.id);
|
||||
return [...prev, newMessage];
|
||||
});
|
||||
};
|
||||
|
||||
// Handle presence: user joined
|
||||
const presenceJoinHandler = (data: { userId: string; requestId: string }) => {
|
||||
console.log('[WorkNoteChat] 🟢 presence:join received - userId:', data.userId, 'requestId:', data.requestId);
|
||||
setParticipants(prev => {
|
||||
if (prev.length === 0) {
|
||||
console.log('[WorkNoteChat] ⚠️ Cannot update presence:join - no participants loaded yet');
|
||||
return prev;
|
||||
}
|
||||
const participant = prev.find(p => (p as any).userId === data.userId);
|
||||
if (!participant) {
|
||||
console.log('[WorkNoteChat] ⚠️ User not found in participants list:', data.userId);
|
||||
return prev;
|
||||
}
|
||||
const updated = prev.map(p =>
|
||||
(p as any).userId === data.userId ? { ...p, status: 'online' as const } : p
|
||||
);
|
||||
console.log('[WorkNoteChat] ✅ Marked user as online:', participant.name, '- Total online:', updated.filter(p => p.status === 'online').length);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle presence: user left
|
||||
const presenceLeaveHandler = (data: { userId: string; requestId: string }) => {
|
||||
console.log('[WorkNoteChat] 🔴 presence:leave received - userId:', data.userId, 'requestId:', data.requestId);
|
||||
|
||||
// Never mark self as offline in own browser
|
||||
if (data.userId === currentUserId) {
|
||||
console.log('[WorkNoteChat] ⚠️ Ignoring presence:leave for self - staying online in own view');
|
||||
return;
|
||||
}
|
||||
|
||||
setParticipants(prev => {
|
||||
if (prev.length === 0) {
|
||||
console.log('[WorkNoteChat] ⚠️ Cannot update presence:leave - no participants loaded yet');
|
||||
return prev;
|
||||
}
|
||||
const participant = prev.find(p => (p as any).userId === data.userId);
|
||||
if (!participant) {
|
||||
console.log('[WorkNoteChat] ⚠️ User not found in participants list:', data.userId);
|
||||
return prev;
|
||||
}
|
||||
const updated = prev.map(p =>
|
||||
(p as any).userId === data.userId ? { ...p, status: 'offline' as const } : p
|
||||
);
|
||||
console.log('[WorkNoteChat] ✅ Marked user as offline:', participant.name, '- Total online:', updated.filter(p => p.status === 'online').length);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle initial online users list
|
||||
const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => {
|
||||
// Presence update received - logging removed
|
||||
setParticipants(prev => {
|
||||
if (prev.length === 0) {
|
||||
console.log('[WorkNoteChat] ⚠️ No participants loaded yet, cannot update online status. Response will be ignored.');
|
||||
return prev;
|
||||
}
|
||||
|
||||
@ -608,36 +592,26 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
|
||||
// Handle socket reconnection
|
||||
const connectHandler = () => {
|
||||
console.log('[WorkNoteChat] 🔌 Socket connected/reconnected');
|
||||
|
||||
// Mark self as online on connection
|
||||
setParticipants(prev => {
|
||||
const updated = prev.map(p =>
|
||||
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
|
||||
);
|
||||
const selfParticipant = prev.find(p => (p as any).userId === currentUserId);
|
||||
if (selfParticipant) {
|
||||
console.log('[WorkNoteChat] 🟢 Marked self as online on connect:', selfParticipant.name);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Rejoin room if needed
|
||||
if (!skipSocketJoin) {
|
||||
joinRequestRoom(s, joinedId, currentUserId);
|
||||
console.log('[WorkNoteChat] 🔄 Rejoined request room on reconnection');
|
||||
}
|
||||
|
||||
// Request online users on connection with multiple retries
|
||||
if (participantsLoadedRef.current) {
|
||||
console.log('[WorkNoteChat] 📡 Requesting online users after connection...');
|
||||
s.emit('request:online-users', { requestId: joinedId });
|
||||
|
||||
// Send additional requests with delay to ensure we get the response
|
||||
setTimeout(() => s.emit('request:online-users', { requestId: joinedId }), 300);
|
||||
setTimeout(() => s.emit('request:online-users', { requestId: joinedId }), 800);
|
||||
} else {
|
||||
console.log('[WorkNoteChat] ⏳ Participants not loaded yet, will request online users when they load');
|
||||
}
|
||||
};
|
||||
|
||||
@ -705,7 +679,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
// Only leave room if we joined it
|
||||
if (!skipSocketJoin) {
|
||||
leaveRequestRoom(s, joinedId);
|
||||
console.log('[WorkNoteChat] 🚪 Emitting leave:request for room (standalone mode)');
|
||||
}
|
||||
socketRef.current = null;
|
||||
// Socket cleanup completed - logging removed
|
||||
@ -729,7 +702,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
const participant = participants.find(p =>
|
||||
p.name.toLowerCase().includes(mentionedName.toLowerCase())
|
||||
);
|
||||
console.log('[Mention Match] Looking for:', mentionedName, 'Found participant:', participant ? `${participant.name} (${(participant as any)?.userId})` : 'NOT FOUND');
|
||||
return (participant as any)?.userId;
|
||||
})
|
||||
.filter(Boolean);
|
||||
@ -878,20 +850,13 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
(async () => {
|
||||
try {
|
||||
const rows = await getWorkNotes(effectiveRequestId);
|
||||
console.log('[WorkNoteChat] Loaded work notes from backend:', rows);
|
||||
|
||||
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||
const userName = m.userName || m.user_name || 'User';
|
||||
const userRole = m.userRole || m.user_role; // Get role directly from backend
|
||||
const participantRole = getFormattedRole(userRole);
|
||||
const noteUserId = m.userId || m.user_id;
|
||||
|
||||
console.log('[WorkNoteChat] Mapping note:', {
|
||||
rawNote: m,
|
||||
extracted: { userName, userRole, participantRole }
|
||||
});
|
||||
|
||||
return {
|
||||
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||
const userName = m.userName || m.user_name || 'User';
|
||||
const userRole = m.userRole || m.user_role; // Get role directly from backend
|
||||
const participantRole = getFormattedRole(userRole);
|
||||
const noteUserId = m.userId || m.user_id;
|
||||
|
||||
return {
|
||||
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
||||
user: {
|
||||
name: userName,
|
||||
@ -1036,7 +1001,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
|
||||
// Request updated online users list from server to get correct status
|
||||
if (socketRef.current && socketRef.current.connected) {
|
||||
console.log('[WorkNoteChat] 📡 Requesting online users after adding spectator...');
|
||||
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
|
||||
}
|
||||
}
|
||||
@ -1165,7 +1129,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[Extract Mentions] Found:', mentions, 'from text:', text);
|
||||
return mentions;
|
||||
};
|
||||
|
||||
@ -1745,40 +1708,43 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 flex-shrink-0">
|
||||
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
|
||||
<div className="space-y-2">
|
||||
{/* Only initiator can add approvers */}
|
||||
{isInitiator && (
|
||||
{/* Quick Actions Section - Hide for spectators */}
|
||||
{!effectiveIsSpectator && (
|
||||
<div className="p-4 sm:p-6 flex-shrink-0">
|
||||
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
|
||||
<div className="space-y-2">
|
||||
{/* Only initiator can add approvers */}
|
||||
{isInitiator && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 text-sm"
|
||||
onClick={() => setShowAddApproverModal(true)}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add Approver
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 text-sm"
|
||||
onClick={() => setShowAddApproverModal(true)}
|
||||
onClick={() => setShowAddSpectatorModal(true)}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add Approver
|
||||
<Eye className="h-4 w-4" />
|
||||
Add Spectator
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 text-sm"
|
||||
onClick={() => setShowAddSpectatorModal(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Add Spectator
|
||||
</Button>
|
||||
{/* <Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
|
||||
<Bell className="h-4 w-4" />
|
||||
Manage Notifications
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive Chat
|
||||
</Button> */}
|
||||
{/* <Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
|
||||
<Bell className="h-4 w-4" />
|
||||
Manage Notifications
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive Chat
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1796,18 +1762,20 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Spectator Modal */}
|
||||
<AddSpectatorModal
|
||||
open={showAddSpectatorModal}
|
||||
onClose={() => setShowAddSpectatorModal(false)}
|
||||
onConfirm={handleAddSpectator}
|
||||
requestIdDisplay={effectiveRequestId}
|
||||
requestTitle={requestInfo.title}
|
||||
existingParticipants={existingParticipants}
|
||||
/>
|
||||
{/* Add Spectator Modal - Hide for spectators */}
|
||||
{!effectiveIsSpectator && (
|
||||
<AddSpectatorModal
|
||||
open={showAddSpectatorModal}
|
||||
onClose={() => setShowAddSpectatorModal(false)}
|
||||
onConfirm={handleAddSpectator}
|
||||
requestIdDisplay={effectiveRequestId}
|
||||
requestTitle={requestInfo.title}
|
||||
existingParticipants={existingParticipants}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Approver Modal */}
|
||||
{isInitiator && (
|
||||
{/* Add Approver Modal - Hide for spectators */}
|
||||
{!effectiveIsSpectator && isInitiator && (
|
||||
<AddApproverModal
|
||||
open={showAddApproverModal}
|
||||
onClose={() => setShowAddApproverModal(false)}
|
||||
|
||||
@ -150,11 +150,8 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
||||
const n = payload?.note || payload;
|
||||
if (!n) return;
|
||||
|
||||
console.log('[WorkNoteChat] Received note via socket:', n);
|
||||
|
||||
setMessages(prev => {
|
||||
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
|
||||
console.log('[WorkNoteChat] Duplicate detected, skipping');
|
||||
return prev;
|
||||
}
|
||||
|
||||
@ -175,7 +172,6 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
||||
isCurrentUser: noteUserId === currentUserId
|
||||
} as any;
|
||||
|
||||
console.log('[WorkNoteChat] Adding new message:', newMsg);
|
||||
return [...prev, newMsg];
|
||||
});
|
||||
};
|
||||
@ -265,7 +261,6 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
||||
(async () => {
|
||||
try {
|
||||
const rows = await getWorkNotes(requestId);
|
||||
console.log('[WorkNoteChat] Loaded work notes:', rows);
|
||||
|
||||
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||
const userName = m.userName || m.user_name || 'User';
|
||||
|
||||
@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon, FileEdit } from 'lucide-react';
|
||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||
import { updateBreachReason as updateBreachReasonApi } from '@/services/workflowApi';
|
||||
@ -339,7 +339,7 @@ export function ApprovalStepCard({
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-600">Due by:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{approval.sla.deadline ? formatDateTime(approval.sla.deadline) : 'Not set'}
|
||||
{approval.sla.deadline ? formatDateDDMMYYYY(approval.sla.deadline, true) : 'Not set'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -532,13 +532,13 @@ export function ApprovalStepCard({
|
||||
<div className="bg-white/50 rounded px-2 py-1">
|
||||
<span className="text-gray-500">Allocated:</span>
|
||||
<span className="ml-1 font-medium text-gray-900">
|
||||
{Number(alert.tatHoursAllocated || 0).toFixed(2)}h
|
||||
{formatHoursMinutes(Number(alert.tatHoursAllocated || 0))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/50 rounded px-2 py-1">
|
||||
<span className="text-gray-500">Elapsed:</span>
|
||||
<span className="ml-1 font-medium text-gray-900">
|
||||
{Number(alert.tatHoursElapsed || 0).toFixed(2)}h
|
||||
{formatHoursMinutes(Number(alert.tatHoursElapsed || 0))}
|
||||
{alert.metadata?.tatTestMode && (
|
||||
<span className="text-purple-600 ml-1">
|
||||
({(Number(alert.tatHoursElapsed || 0) * 60).toFixed(0)}m)
|
||||
@ -551,7 +551,7 @@ export function ApprovalStepCard({
|
||||
<span className={`ml-1 font-medium ${
|
||||
(alert.tatHoursRemaining || 0) < 2 ? 'text-red-600' : 'text-gray-900'
|
||||
}`}>
|
||||
{Number(alert.tatHoursRemaining || 0).toFixed(2)}h
|
||||
{formatHoursMinutes(Number(alert.tatHoursRemaining || 0))}
|
||||
{alert.metadata?.tatTestMode && (
|
||||
<span className="text-purple-600 ml-1">
|
||||
({(Number(alert.tatHoursRemaining || 0) * 60).toFixed(0)}m)
|
||||
@ -562,7 +562,7 @@ export function ApprovalStepCard({
|
||||
<div className="bg-white/50 rounded px-2 py-1">
|
||||
<span className="text-gray-500">Due by:</span>
|
||||
<span className="ml-1 font-medium text-gray-900">
|
||||
{alert.expectedCompletionTime ? formatDateShort(alert.expectedCompletionTime) : 'N/A'}
|
||||
{alert.expectedCompletionTime ? formatDateDDMMYYYY(alert.expectedCompletionTime, true) : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,7 @@ import { Progress } from '@/components/ui/progress';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
@ -261,7 +262,7 @@ export function ClaimManagementDetail({
|
||||
</div>
|
||||
<Progress value={claim.slaProgress} className="h-2 mb-2" />
|
||||
<p className="text-xs text-gray-600">
|
||||
Due: {claim.slaEndDate} • {claim.slaProgress}% elapsed
|
||||
Due: {formatDateDDMMYYYY(claim.slaEndDate, true)} • {claim.slaProgress}% elapsed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -762,8 +763,7 @@ export function ClaimManagementDetail({
|
||||
<DealerDocumentModal
|
||||
isOpen={dealerDocModal}
|
||||
onClose={() => setDealerDocModal(false)}
|
||||
onSubmit={async (documents) => {
|
||||
console.log('Dealer documents submitted:', documents);
|
||||
onSubmit={async (_documents) => {
|
||||
toast.success('Documents Uploaded', {
|
||||
description: 'Your documents have been submitted for review.',
|
||||
});
|
||||
@ -779,7 +779,6 @@ export function ClaimManagementDetail({
|
||||
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.`,
|
||||
});
|
||||
|
||||
@ -5,10 +5,9 @@ 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, AlertCircle, Info, Clock, Minus, Plus } from 'lucide-react';
|
||||
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus } from 'lucide-react';
|
||||
import { FormData } from '@/hooks/useCreateRequestForm';
|
||||
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { ensureUserExists } from '@/services/userApi';
|
||||
|
||||
interface ApprovalWorkflowStepProps {
|
||||
@ -35,7 +34,6 @@ export function ApprovalWorkflowStep({
|
||||
updateFormData,
|
||||
onValidationError
|
||||
}: ApprovalWorkflowStepProps) {
|
||||
const { user } = useAuth();
|
||||
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
||||
|
||||
const handleApproverEmailChange = (index: number, value: string) => {
|
||||
|
||||
@ -41,7 +41,7 @@ export function DocumentsStep({
|
||||
existingDocuments,
|
||||
documentsToDelete,
|
||||
onDocumentsChange,
|
||||
onExistingDocumentsChange,
|
||||
onExistingDocumentsChange: _onExistingDocumentsChange,
|
||||
onDocumentsToDeleteChange,
|
||||
onPreviewDocument,
|
||||
onDocumentErrors,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
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';
|
||||
@ -8,7 +8,6 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Eye, Info, X } from 'lucide-react';
|
||||
import { FormData } from '@/hooks/useCreateRequestForm';
|
||||
import { useUserSearch } from '@/hooks/useUserSearch';
|
||||
import { ensureUserExists } from '@/services/userApi';
|
||||
|
||||
interface ParticipantsStepProps {
|
||||
formData: FormData;
|
||||
|
||||
@ -3,7 +3,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, Rocket, FileText, Users, Eye, Upload, Flame, Target, TrendingUp, DollarSign } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||
|
||||
interface ReviewSubmitStepProps {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
@ -74,9 +74,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
const forceLogout = sessionStorage.getItem('__force_logout__');
|
||||
|
||||
if (logoutFlag === 'true' || forceLogout === 'true') {
|
||||
console.log('🔴 Logout flag detected - PREVENTING auto-authentication');
|
||||
console.log('🔴 Clearing ALL authentication data and showing login screen');
|
||||
|
||||
// Remove flags
|
||||
sessionStorage.removeItem('__logout_in_progress__');
|
||||
sessionStorage.removeItem('__force_logout__');
|
||||
@ -98,17 +95,12 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
|
||||
console.log('🔴 Logout complete - user should see login screen');
|
||||
return;
|
||||
}
|
||||
|
||||
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('logout') || urlParams.has('okta_logged_out')) {
|
||||
console.log('🔴 Logout parameter in URL - clearing everything', {
|
||||
hasLogout: urlParams.has('logout'),
|
||||
hasOktaLoggedOut: urlParams.has('okta_logged_out'),
|
||||
});
|
||||
TokenManager.clearAll();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
@ -133,7 +125,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// If no auth data exists, we're likely after a logout - set unauthenticated state immediately
|
||||
if (!hasAuthData) {
|
||||
console.log('🔴 No auth data found - setting unauthenticated state');
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
setIsLoading(false);
|
||||
@ -144,7 +135,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
if (!isLoggingOut) {
|
||||
checkAuthStatus();
|
||||
} else {
|
||||
console.log('🔴 Skipping checkAuthStatus - logout in progress');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoggingOut]);
|
||||
@ -211,12 +201,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
// This is the frontend callback URL, NOT the backend URL
|
||||
// Backend will use this same URI when exchanging code with Okta
|
||||
const redirectUri = `${window.location.origin}/login/callback`;
|
||||
console.log('📥 Authorization Code Received:', {
|
||||
code: code.substring(0, 10) + '...',
|
||||
redirectUri,
|
||||
fullUrl: window.location.href,
|
||||
note: 'redirectUri is frontend URL (not backend) - must match Okta registration',
|
||||
});
|
||||
|
||||
const result = await exchangeCodeForTokens(code, redirectUri);
|
||||
|
||||
@ -244,7 +228,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
const checkAuthStatus = async () => {
|
||||
// Don't check auth status if we're in the middle of logging out
|
||||
if (isLoggingOut) {
|
||||
console.log('🔴 Skipping checkAuthStatus - logout in progress');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@ -254,12 +237,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const token = TokenManager.getAccessToken();
|
||||
const storedUser = TokenManager.getUserData();
|
||||
|
||||
console.log('🔍 Checking auth status:', { hasToken: !!token, hasUser: !!storedUser, isLoggingOut });
|
||||
|
||||
// If no token at all, user is not authenticated
|
||||
if (!token) {
|
||||
console.log('🔍 No token found - setting unauthenticated');
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
setIsLoading(false);
|
||||
@ -361,7 +341,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
// This ensures Okta requires login even if a session still exists
|
||||
if (isAfterLogout) {
|
||||
authUrl += `&prompt=login`;
|
||||
console.log('🔐 Adding prompt=login to force re-authentication after logout');
|
||||
}
|
||||
|
||||
window.location.href = authUrl;
|
||||
@ -372,13 +351,11 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
console.log('🚪 LOGOUT FUNCTION CALLED - Starting logout process');
|
||||
console.log('🚪 Current auth state:', { isAuthenticated, hasUser: !!user, isLoading });
|
||||
|
||||
try {
|
||||
// CRITICAL: Get id_token from TokenManager before clearing anything
|
||||
// Okta logout endpoint works better with id_token_hint to properly end the session
|
||||
const idToken = TokenManager.getIdToken();
|
||||
// Note: Currently not used but kept for future Okta integration
|
||||
void TokenManager.getIdToken();
|
||||
|
||||
// Set logout flag to prevent auto-authentication after redirect
|
||||
// This must be set BEFORE clearing storage so it survives
|
||||
@ -386,20 +363,16 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
sessionStorage.setItem('__force_logout__', 'true');
|
||||
setIsLoggingOut(true);
|
||||
|
||||
console.log('🚪 Step 1: Resetting auth state...');
|
||||
// Reset auth state FIRST to prevent any re-authentication
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
setError(null);
|
||||
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
|
||||
console.log('🚪 Step 1: Auth state reset complete');
|
||||
|
||||
// Call backend logout API to clear server-side session and httpOnly cookies
|
||||
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
|
||||
try {
|
||||
console.log('🚪 Step 2: Calling backend logout API to clear httpOnly cookies...');
|
||||
await logoutApi();
|
||||
console.log('🚪 Step 2: Backend logout API completed - httpOnly cookies should be cleared');
|
||||
} catch (err) {
|
||||
console.error('🚪 Logout API error:', err);
|
||||
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
||||
@ -407,16 +380,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
// 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__');
|
||||
@ -429,18 +392,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
||||
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
||||
|
||||
console.log('🚪 Local storage cleared (logout flags preserved)');
|
||||
|
||||
// Final verification BEFORE redirect
|
||||
console.log('🚪 Final verification - logout flags preserved:', {
|
||||
logoutInProgress: sessionStorage.getItem('__logout_in_progress__'),
|
||||
forceLogout: sessionStorage.getItem('__force_logout__'),
|
||||
});
|
||||
|
||||
console.log('🚪 Clearing local session and redirecting to login...');
|
||||
console.log('🚪 Using prompt=login on next auth to force re-authentication');
|
||||
console.log('🚪 This will prevent auto-authentication even if Okta session exists');
|
||||
|
||||
// Small delay to ensure sessionStorage is written before redirect
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
@ -523,11 +474,8 @@ export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
|
||||
authorizationParams={{
|
||||
redirect_uri: window.location.origin + '/login/callback',
|
||||
}}
|
||||
onRedirectCallback={(appState) => {
|
||||
console.log('Auth0 Redirect Callback:', {
|
||||
appState,
|
||||
returnTo: appState?.returnTo || window.location.pathname,
|
||||
});
|
||||
onRedirectCallback={(_appState) => {
|
||||
// Auth0 redirect callback handled
|
||||
}}
|
||||
>
|
||||
<Auth0ContextWrapper>{children}</Auth0ContextWrapper>
|
||||
|
||||
@ -71,7 +71,6 @@ export function useConclusionRemark(
|
||||
}
|
||||
} catch (err) {
|
||||
// No conclusion yet - this is expected for newly approved requests
|
||||
console.log('[useConclusionRemark] No existing conclusion found');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getWorkflowDetails } from '@/services/workflowApi';
|
||||
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
|
||||
export interface RequestTemplate {
|
||||
id: string;
|
||||
@ -127,7 +126,6 @@ export function useCreateRequestForm(
|
||||
editRequestId: string,
|
||||
templates: RequestTemplate[]
|
||||
) {
|
||||
const { user } = useAuth();
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<RequestTemplate | null>(null);
|
||||
const [loadingDraft, setLoadingDraft] = useState(isEditing);
|
||||
@ -148,7 +146,7 @@ export function useCreateRequestForm(
|
||||
const loadPolicies = async () => {
|
||||
try {
|
||||
// Load document policy
|
||||
const docConfigs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||
const docConfigs = await getPublicConfigurations('DOCUMENT_POLICY');
|
||||
const docConfigMap: Record<string, string> = {};
|
||||
docConfigs.forEach((c: AdminConfiguration) => {
|
||||
docConfigMap[c.configKey] = c.configValue;
|
||||
@ -164,8 +162,8 @@ export function useCreateRequestForm(
|
||||
});
|
||||
|
||||
// Load system policy
|
||||
const workflowConfigs = await getAllConfigurations('WORKFLOW_SHARING');
|
||||
const tatConfigs = await getAllConfigurations('TAT_SETTINGS');
|
||||
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) => {
|
||||
|
||||
@ -29,7 +29,7 @@ export interface PreviewDocument {
|
||||
*/
|
||||
export function useDocumentManagement(
|
||||
documentPolicy: DocumentPolicy,
|
||||
isEditing: boolean = false
|
||||
_isEditing: boolean = false
|
||||
) {
|
||||
const [documents, setDocuments] = useState<File[]>([]);
|
||||
const [existingDocuments, setExistingDocuments] = useState<any[]>([]);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { uploadDocument } from '@/services/documentApi';
|
||||
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
@ -57,7 +57,7 @@ export function useDocumentUpload(
|
||||
useEffect(() => {
|
||||
const loadDocumentPolicy = async () => {
|
||||
try {
|
||||
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||
const configs = await getPublicConfigurations('DOCUMENT_POLICY');
|
||||
const configMap: Record<string, string> = {};
|
||||
configs.forEach((c: AdminConfiguration) => {
|
||||
configMap[c.configKey] = c.configValue;
|
||||
|
||||
@ -237,8 +237,10 @@ export function useRequestDetails(
|
||||
},
|
||||
createdAt: wf.createdAt,
|
||||
updatedAt: wf.updatedAt,
|
||||
totalSteps: wf.totalLevels,
|
||||
currentStep: summary?.currentLevel || wf.currentLevel,
|
||||
totalSteps: wf.totalLevels || 1,
|
||||
// Store both raw and clamped values - raw for completion detection, clamped for display
|
||||
currentStepRaw: summary?.currentLevel || wf.currentLevel || 1,
|
||||
currentStep: Math.min(Math.max(1, summary?.currentLevel || wf.currentLevel || 1), wf.totalLevels || 1),
|
||||
auditTrail: filteredActivities,
|
||||
conclusionRemark: wf.conclusionRemark || null,
|
||||
closureDate: wf.closureDate || null,
|
||||
@ -407,8 +409,10 @@ export function useRequestDetails(
|
||||
},
|
||||
createdAt: wf.createdAt,
|
||||
updatedAt: wf.updatedAt,
|
||||
totalSteps: wf.totalLevels,
|
||||
currentStep: summary?.currentLevel || wf.currentLevel,
|
||||
totalSteps: wf.totalLevels || 1,
|
||||
// Store both raw and clamped values - raw for completion detection, clamped for display
|
||||
currentStepRaw: summary?.currentLevel || wf.currentLevel || 1,
|
||||
currentStep: Math.min(Math.max(1, summary?.currentLevel || wf.currentLevel || 1), wf.totalLevels || 1),
|
||||
approvalFlow,
|
||||
approvals,
|
||||
documents: mappedDocuments,
|
||||
|
||||
@ -208,9 +208,7 @@ export function useRequestSocket(
|
||||
* 3. Show browser notification if permission granted
|
||||
*/
|
||||
const handleTatAlert = (data: any) => {
|
||||
// TAT alert received - single line log only
|
||||
const alertEmoji = data.type === 'breach' ? '⏰' : data.type === 'threshold2' ? '⚠️' : '⏳';
|
||||
console.log(`TAT Alert: ${data.message}`);
|
||||
|
||||
// Refresh: Get updated TAT alerts from backend
|
||||
(async () => {
|
||||
@ -218,8 +216,8 @@ export function useRequestSocket(
|
||||
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||
|
||||
if (details) {
|
||||
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||
console.log(`[useRequestSocket] Refreshed TAT alerts:`, tatAlerts);
|
||||
// Extract TAT alerts for potential future use
|
||||
void (Array.isArray(details.tatAlerts) ? details.tatAlerts : []);
|
||||
|
||||
// Browser notification (if user granted permission)
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
|
||||
@ -4,13 +4,6 @@ import { AuthProvider } from './contexts/AuthContext';
|
||||
import { AuthenticatedApp } from './pages/Auth';
|
||||
import './styles/globals.css';
|
||||
|
||||
console.log('Application Starting...');
|
||||
console.log('Environment:', {
|
||||
hostname: window.location.hostname,
|
||||
origin: window.location.origin,
|
||||
isLocalhost: window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1',
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* Approver's Actions Statistics Component
|
||||
*/
|
||||
|
||||
import { CheckCircle, XCircle, Clock, FileText, Users, Target, Award, AlertCircle, BarChart3 } from 'lucide-react';
|
||||
import { CheckCircle, XCircle, Clock, FileText, Users, Target, Award, AlertCircle, BarChart3, Archive } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { ApproverPerformance } from '@/services/dashboard.service';
|
||||
import type { ApproverPerformanceStats } from '../types/approverPerformance.types';
|
||||
@ -33,7 +33,7 @@ export function ApproverPerformanceActionsStats({
|
||||
<Users className="w-4 h-4" />
|
||||
Approver's Actions
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
@ -61,6 +61,13 @@ export function ApproverPerformanceActionsStats({
|
||||
<div className="text-2xl font-bold text-yellow-700">{calculatedStats.pendingByApprover}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">Pending Actions</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Archive className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-700">{calculatedStats.closedByApprover}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">Closed Requests</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
* Type definitions for Approver Performance page
|
||||
*/
|
||||
|
||||
import type { ApproverPerformance } from '@/services/dashboard.service';
|
||||
|
||||
export interface ApproverPerformanceStats {
|
||||
total: number;
|
||||
approvedByApprover: number;
|
||||
rejectedByApprover: number;
|
||||
pendingByApprover: number;
|
||||
closedByApprover: number;
|
||||
breached: number;
|
||||
compliant: number;
|
||||
approvalRate: number;
|
||||
|
||||
@ -25,6 +25,13 @@ export function calculateApproverStats(
|
||||
return ['pending', 'in_progress', 'in-progress'].includes(status);
|
||||
}).length;
|
||||
|
||||
// Count closed requests - check overall request status (wf.status = 'CLOSED')
|
||||
// Closed requests are those that have been finalized with a conclusion remark
|
||||
const closedByApprover = filtered.filter(r => {
|
||||
const requestStatus = (r.status || '').toLowerCase();
|
||||
return requestStatus === 'closed';
|
||||
}).length;
|
||||
|
||||
// TAT compliance stats - check isBreached flag OR slaStatus === 'breached'
|
||||
// This includes pending requests that have breached their TAT
|
||||
const breached = filtered.filter(r => {
|
||||
@ -72,6 +79,7 @@ export function calculateApproverStats(
|
||||
approvedByApprover,
|
||||
rejectedByApprover,
|
||||
pendingByApprover,
|
||||
closedByApprover,
|
||||
// TAT stats
|
||||
breached,
|
||||
compliant,
|
||||
|
||||
@ -6,28 +6,13 @@ import { LogIn, Shield } from 'lucide-react';
|
||||
export function Auth() {
|
||||
const { login, isLoading, error } = useAuth();
|
||||
|
||||
console.log('Auth Component Render - Auth0 State:', {
|
||||
isLoading,
|
||||
error: error?.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const handleSSOLogin = async () => {
|
||||
console.log('========================================');
|
||||
console.log('SSO LOGIN - Button Clicked');
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
console.log('Current URL:', window.location.href);
|
||||
console.log('========================================');
|
||||
|
||||
// Clear any existing session data
|
||||
console.log('Clearing local storage and session storage...');
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
console.log('Storage cleared');
|
||||
|
||||
try {
|
||||
await login();
|
||||
console.log('Login redirect initiated successfully');
|
||||
} catch (loginError) {
|
||||
console.error('========================================');
|
||||
console.error('LOGIN ERROR');
|
||||
|
||||
@ -13,27 +13,18 @@ export function AuthenticatedApp() {
|
||||
const isCallbackRoute = typeof window !== 'undefined' && window.location.pathname === '/login/callback';
|
||||
|
||||
const handleLogout = async () => {
|
||||
console.log('🔵 ========================================');
|
||||
console.log('🔵 AuthenticatedApp.handleLogout - CALLED');
|
||||
console.log('🔵 Timestamp:', new Date().toISOString());
|
||||
console.log('🔵 logout function exists?', !!logout);
|
||||
console.log('🔵 ========================================');
|
||||
|
||||
try {
|
||||
if (!logout) {
|
||||
console.error('🔵 ERROR: logout function is undefined!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔵 Calling logout from auth context...');
|
||||
// Call logout from auth context (handles all cleanup and redirect)
|
||||
await logout();
|
||||
console.log('🔵 Logout successful - redirecting to login...');
|
||||
} catch (logoutError) {
|
||||
console.error('🔵 Logout error in handleLogout:', logoutError);
|
||||
// Even if logout fails, clear local data and redirect
|
||||
try {
|
||||
console.log('🔵 Attempting emergency cleanup...');
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
window.location.href = '/';
|
||||
@ -44,33 +35,7 @@ export function AuthenticatedApp() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('AuthenticatedApp - Auth State Changed:', {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
error: error?.message,
|
||||
hasUser: !!user,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (user) {
|
||||
console.log('========================================');
|
||||
console.log('USER AUTHENTICATED - Full Details');
|
||||
console.log('========================================');
|
||||
console.log('User ID:', user.userId || user.sub);
|
||||
console.log('Employee ID:', user.employeeId);
|
||||
console.log('Email:', user.email);
|
||||
console.log('Name:', user.displayName || user.name);
|
||||
console.log('Display Name:', user.displayName);
|
||||
console.log('First Name:', user.firstName);
|
||||
console.log('Last Name:', user.lastName);
|
||||
console.log('Department:', user.department);
|
||||
console.log('Designation:', user.designation);
|
||||
console.log('Role:', user.role);
|
||||
console.log('========================================');
|
||||
console.log('ALL USER DATA:');
|
||||
console.log(JSON.stringify(user, null, 2));
|
||||
console.log('========================================');
|
||||
}
|
||||
// Auth state changed
|
||||
}, [isAuthenticated, isLoading, error, user]);
|
||||
|
||||
// Always show callback loader when on callback route (after all hooks)
|
||||
@ -80,7 +45,6 @@ export function AuthenticatedApp() {
|
||||
|
||||
// Show loading state while checking authentication
|
||||
if (isLoading) {
|
||||
console.log('Auth0 is still loading...');
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="text-center">
|
||||
@ -112,12 +76,10 @@ export function AuthenticatedApp() {
|
||||
|
||||
// Show login screen if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
console.log('User not authenticated, showing login screen');
|
||||
return <Auth />;
|
||||
}
|
||||
|
||||
// User is authenticated, show the app
|
||||
console.log('User authenticated successfully, showing main application');
|
||||
return (
|
||||
<>
|
||||
<App onLogout={handleLogout} />
|
||||
|
||||
@ -12,7 +12,7 @@ import { useClosedRequests } from './hooks/useClosedRequests';
|
||||
import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
|
||||
|
||||
// Types
|
||||
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
||||
import type { ClosedRequestsProps, ClosedRequestsFilters } from './types/closedRequests.types';
|
||||
|
||||
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
// Data fetching hook
|
||||
@ -24,7 +24,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
|
||||
const filters = useClosedRequestsFilters({
|
||||
onFiltersChange: useCallback(
|
||||
(filters) => {
|
||||
(filters: ClosedRequestsFilters) => {
|
||||
// Reset to page 1 when filters change
|
||||
fetchRef.current(1, {
|
||||
search: filters.search || undefined,
|
||||
|
||||
@ -6,7 +6,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ArrowRight, Calendar, CheckCircle } from 'lucide-react';
|
||||
import { formatDateTime } from '@/utils/dateFormatter';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
import { ClosedRequest } from '../types/closedRequests.types';
|
||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
|
||||
@ -88,13 +88,13 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>Created: {request.createdAt !== '—' ? formatDateTime(request.createdAt) : '—'}</span>
|
||||
<span>Created: {request.createdAt !== '—' ? formatDateDDMMYYYY(request.createdAt, true) : '—'}</span>
|
||||
</div>
|
||||
|
||||
{request.dueDate && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-slate-600" />
|
||||
<span className="font-medium">Closed: {formatDateTime(request.dueDate)}</span>
|
||||
<span className="font-medium">Closed: {formatDateDDMMYYYY(request.dueDate, true)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -110,12 +110,6 @@ export function ClosedRequestsFilters({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="approved">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="closed">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-gray-600" />
|
||||
|
||||
@ -66,7 +66,7 @@ export function ClosedRequestsPagination({
|
||||
variant={pageNum === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
|
||||
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
|
||||
data-testid={`closed-requests-pagination-page-${pageNum}`}
|
||||
>
|
||||
{pageNum}
|
||||
|
||||
@ -41,11 +41,28 @@ export function useClosedRequests({ itemsPerPage = 10, initialFilters }: UseClos
|
||||
// Always use user-scoped endpoint (not organization-wide)
|
||||
// Backend filters by userId regardless of user role (ADMIN/MANAGEMENT/regular user)
|
||||
// For organization-wide requests, use the "All Requests" screen (/requests)
|
||||
// Only fetch rejected and closed requests - exclude approved
|
||||
let statusFilter = filters?.status;
|
||||
|
||||
// If user somehow selects approved (shouldn't be possible now), don't fetch
|
||||
if (statusFilter === 'approved') {
|
||||
setRequests([]);
|
||||
setPagination({
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
totalRecords: 0,
|
||||
itemsPerPage,
|
||||
});
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await workflowApi.listClosedByMe({
|
||||
page,
|
||||
limit: itemsPerPage,
|
||||
search: filters?.search,
|
||||
status: filters?.status,
|
||||
status: statusFilter && statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: filters?.priority,
|
||||
sortBy: filters?.sortBy,
|
||||
sortOrder: filters?.sortOrder
|
||||
@ -56,9 +73,23 @@ export function useClosedRequests({ itemsPerPage = 10, initialFilters }: UseClos
|
||||
? (result as any).data
|
||||
: [];
|
||||
|
||||
const mapped = transformClosedRequests(data);
|
||||
// Filter out approved requests - only show rejected and closed
|
||||
const filtered = mapped.filter(request =>
|
||||
request.status === 'rejected' || request.status === 'closed'
|
||||
);
|
||||
setRequests(filtered);
|
||||
|
||||
// Set pagination data
|
||||
// Note: Since we're filtering out approved requests client-side,
|
||||
// the pagination count from backend may include approved requests.
|
||||
// We'll use the filtered count for this page, but total records
|
||||
// from backend is approximate.
|
||||
const paginationData = (result as any)?.pagination;
|
||||
if (paginationData) {
|
||||
// If we filtered out some requests on this page, we need to adjust pagination
|
||||
// For simplicity, we'll keep backend's total but note it may be slightly off
|
||||
// A better solution would be to filter on backend, but for now this works
|
||||
setPagination({
|
||||
currentPage: paginationData.page || 1,
|
||||
totalPages: paginationData.totalPages || 1,
|
||||
@ -66,9 +97,6 @@ export function useClosedRequests({ itemsPerPage = 10, initialFilters }: UseClos
|
||||
itemsPerPage,
|
||||
});
|
||||
}
|
||||
|
||||
const mapped = transformClosedRequests(data);
|
||||
setRequests(mapped);
|
||||
} catch (error) {
|
||||
console.error('[ClosedRequests] Error fetching requests:', error);
|
||||
setRequests([]);
|
||||
|
||||
@ -8,7 +8,7 @@ export interface ClosedRequest {
|
||||
displayId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'approved' | 'rejected' | 'closed';
|
||||
status: 'rejected' | 'closed';
|
||||
priority: 'express' | 'standard';
|
||||
initiator: { name: string; avatar: string };
|
||||
createdAt: string;
|
||||
|
||||
@ -30,14 +30,6 @@ export function getPriorityConfig(priority: string): PriorityConfig {
|
||||
|
||||
export function getStatusConfig(status: string): StatusConfig {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return {
|
||||
color: 'bg-emerald-100 text-emerald-800 border-emerald-300',
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-emerald-600',
|
||||
label: 'Needs Closure',
|
||||
description: 'Fully approved, awaiting initiator to finalize'
|
||||
};
|
||||
case 'closed':
|
||||
return {
|
||||
color: 'bg-slate-100 text-slate-800 border-slate-300',
|
||||
|
||||
@ -11,7 +11,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
|
||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: (r.status || '').toString().toLowerCase() as 'approved' | 'rejected' | 'closed',
|
||||
status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed',
|
||||
priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard',
|
||||
initiator: {
|
||||
name: r.initiator?.displayName || r.initiator?.email || '—',
|
||||
|
||||
@ -32,7 +32,7 @@ interface UseHandlersOptions {
|
||||
}
|
||||
|
||||
export function useCreateRequestHandlers({
|
||||
selectedTemplate,
|
||||
selectedTemplate: _selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
updateFormData,
|
||||
formData,
|
||||
|
||||
@ -157,6 +157,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
dateRange?: DateRange;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
targetPage?: 'requests' | 'open-requests' | 'my-requests';
|
||||
}) => {
|
||||
const url = buildFilterUrl(filters);
|
||||
onNavigate?.(url);
|
||||
@ -211,11 +212,14 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
customStartDate={customStartDate}
|
||||
customEndDate={customEndDate}
|
||||
onKPIClick={handleKPIClick}
|
||||
onNavigate={onNavigate}
|
||||
userId={(user as any)?.userId}
|
||||
userDisplayName={(user as any)?.displayName || (user as any)?.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Alerts and Activity Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6" style={{ height: '60vh', minHeight: '480px' }} data-testid="dashboard-alerts-activity">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6 h-[90vh] min-h-[720px] lg:h-[60vh] lg:min-h-[480px]" data-testid="dashboard-alerts-activity">
|
||||
<CriticalAlertsSection
|
||||
isAdmin={isAdmin}
|
||||
breachedRequests={breachedRequests}
|
||||
|
||||
@ -8,7 +8,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Activity, MessageSquare, PieChart, Download } from 'lucide-react';
|
||||
import { DashboardKPIs, DepartmentStats, UpcomingDeadline, DateRange } from '@/services/dashboard.service';
|
||||
import { CriticalRequest, CriticalAlertData } from '@/services/dashboard.service';
|
||||
import { CriticalRequest } from '@/services/dashboard.service';
|
||||
import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
|
||||
import { KPIClickFilters } from '../../components/types/dashboard.types';
|
||||
|
||||
|
||||
@ -144,7 +144,7 @@ export function AdminKPICards({
|
||||
{/* Avg Cycle Time */}
|
||||
<KPICard
|
||||
title="Avg Cycle Time"
|
||||
value={kpis?.tatEfficiency.avgCycleTimeHours ? formatHoursMinutes(kpis.tatEfficiency.avgCycleTimeHours) : '0h'}
|
||||
value={kpis?.tatEfficiency.avgCycleTimeHours ? formatHoursMinutes(kpis.tatEfficiency.avgCycleTimeHours) : '0 hours'}
|
||||
icon={Clock}
|
||||
iconBgColor="bg-purple-50"
|
||||
iconColor="text-purple-600"
|
||||
@ -152,37 +152,39 @@ export function AdminKPICards({
|
||||
testId="kpi-avg-cycle-time"
|
||||
onClick={() => onKPIClick(getFilterParams())}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 mt-auto">
|
||||
<StatCard
|
||||
label="Express"
|
||||
value={(() => {
|
||||
const express = priorityDistribution.find((p) => p.priority === 'express');
|
||||
const hours = express ? Number(express.avgCycleTimeHours) : 0;
|
||||
return hours > 0 ? formatHoursMinutes(hours) : 'N/A';
|
||||
})()}
|
||||
bgColor="bg-orange-50"
|
||||
textColor="text-orange-600"
|
||||
testId="stat-express-time"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), priority: 'express' });
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Standard"
|
||||
value={(() => {
|
||||
const standard = priorityDistribution.find((p) => p.priority === 'standard');
|
||||
const hours = standard ? Number(standard.avgCycleTimeHours) : 0;
|
||||
return hours > 0 ? formatHoursMinutes(hours) : 'N/A';
|
||||
})()}
|
||||
bgColor="bg-blue-50"
|
||||
textColor="text-blue-600"
|
||||
testId="stat-standard-time"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), priority: 'standard' });
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="grid grid-cols-2 gap-2 mt-auto">
|
||||
<StatCard
|
||||
label="Express"
|
||||
value={(() => {
|
||||
const express = priorityDistribution.find((p) => p.priority === 'express');
|
||||
const hours = express ? Number(express.avgCycleTimeHours) : 0;
|
||||
return hours > 0 ? formatHoursMinutes(hours) : 'N/A';
|
||||
})()}
|
||||
bgColor="bg-orange-50"
|
||||
textColor="text-orange-600"
|
||||
testId="stat-express-time"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), priority: 'express' });
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Standard"
|
||||
value={(() => {
|
||||
const standard = priorityDistribution.find((p) => p.priority === 'standard');
|
||||
const hours = standard ? Number(standard.avgCycleTimeHours) : 0;
|
||||
return hours > 0 ? formatHoursMinutes(hours) : 'N/A';
|
||||
})()}
|
||||
bgColor="bg-blue-50"
|
||||
textColor="text-blue-600"
|
||||
testId="stat-standard-time"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), priority: 'standard' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</KPICard>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Flame, CheckCircle, ArrowRight } from 'lucide-react';
|
||||
import { CriticalAlertCard } from '@/components/dashboard/CriticalAlertCard';
|
||||
import { CriticalRequest, CriticalAlertData } from '@/services/dashboard.service';
|
||||
import { CriticalRequest } from '@/services/dashboard.service';
|
||||
import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
|
||||
import { getPageNumbers } from '../../utils/dashboardCalculations';
|
||||
|
||||
interface CriticalAlertsSectionProps {
|
||||
|
||||
@ -124,7 +124,7 @@ export function RecentActivitySection({
|
||||
variant={pageNum === pagination.page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className={`h-7 w-7 p-0 text-xs ${pageNum === pagination.page ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
|
||||
className={`h-7 w-7 p-0 text-xs ${pageNum === pagination.page ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
|
||||
data-testid={`activity-pagination-page-${pageNum}`}
|
||||
>
|
||||
{pageNum}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { CriticalRequest, CriticalAlertData, DateRange } from '@/services/dashboard.service';
|
||||
import { CriticalRequest, DateRange } from '@/services/dashboard.service';
|
||||
import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
|
||||
import { Pagination } from '@/components/common/Pagination';
|
||||
import { formatBreachTime } from '../../utils/dashboardCalculations';
|
||||
import { KPIClickFilters } from '../../components/types/dashboard.types';
|
||||
|
||||
@ -8,7 +8,8 @@ import { KPICard } from '@/components/dashboard/KPICard';
|
||||
import { StatCard } from '@/components/dashboard/StatCard';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { DashboardKPIs, DateRange } from '@/services/dashboard.service';
|
||||
import { CriticalRequest, CriticalAlertData } from '@/services/dashboard.service';
|
||||
import { CriticalRequest } from '@/services/dashboard.service';
|
||||
import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
|
||||
import { KPIClickFilters } from '../../components/types/dashboard.types';
|
||||
|
||||
interface UserKPICardsProps {
|
||||
@ -18,6 +19,9 @@ interface UserKPICardsProps {
|
||||
customStartDate?: Date;
|
||||
customEndDate?: Date;
|
||||
onKPIClick: (filters: KPIClickFilters) => void;
|
||||
onNavigate?: (page: string) => void;
|
||||
userId?: string;
|
||||
userDisplayName?: string;
|
||||
}
|
||||
|
||||
export function UserKPICards({
|
||||
@ -27,6 +31,9 @@ export function UserKPICards({
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
onKPIClick,
|
||||
onNavigate,
|
||||
userId,
|
||||
userDisplayName,
|
||||
}: UserKPICardsProps) {
|
||||
const getFilterParams = () => ({
|
||||
dateRange,
|
||||
@ -34,6 +41,19 @@ export function UserKPICards({
|
||||
endDate: customEndDate,
|
||||
});
|
||||
|
||||
const handleNavigateToApproverPerformance = () => {
|
||||
if (!userId) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('approverId', userId);
|
||||
params.set('approverName', userDisplayName || 'My Performance');
|
||||
params.set('dateRange', dateRange);
|
||||
if (dateRange === 'custom' && customStartDate && customEndDate) {
|
||||
params.set('startDate', customStartDate.toISOString());
|
||||
params.set('endDate', customEndDate.toISOString());
|
||||
}
|
||||
onNavigate?.(`/approver-performance?${params.toString()}`);
|
||||
};
|
||||
|
||||
const successRate = kpis && kpis.requestVolume.totalRequests > 0
|
||||
? ((kpis.requestVolume.approvedRequests / kpis.requestVolume.totalRequests) * 100)
|
||||
: 0;
|
||||
@ -74,14 +94,14 @@ export function UserKPICards({
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Draft"
|
||||
value={kpis?.requestVolume.draftRequests || 0}
|
||||
bgColor="bg-gray-50"
|
||||
textColor="text-gray-600"
|
||||
testId="stat-user-draft"
|
||||
label="Rejected"
|
||||
value={kpis?.requestVolume.rejectedRequests || 0}
|
||||
bgColor="bg-red-50"
|
||||
textColor="text-red-600"
|
||||
testId="stat-user-rejected"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), status: 'draft' });
|
||||
onKPIClick({ ...getFilterParams(), status: 'rejected' });
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
@ -107,6 +127,9 @@ export function UserKPICards({
|
||||
iconColor="text-orange-600"
|
||||
subtitle="at current level"
|
||||
testId="kpi-pending-actions"
|
||||
onClick={() => onKPIClick({ ...getFilterParams(), targetPage: 'open-requests', status: 'pending' })}
|
||||
onJustifyClick={handleNavigateToApproverPerformance}
|
||||
showJustifyButton={!!userId}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 mt-auto">
|
||||
<StatCard
|
||||
@ -115,6 +138,10 @@ export function UserKPICards({
|
||||
bgColor="bg-blue-50"
|
||||
textColor="text-blue-600"
|
||||
testId="stat-today"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), targetPage: 'open-requests', status: 'pending' });
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="This Week"
|
||||
@ -122,6 +149,10 @@ export function UserKPICards({
|
||||
bgColor="bg-green-50"
|
||||
textColor="text-green-600"
|
||||
testId="stat-week"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), targetPage: 'open-requests', status: 'pending' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</KPICard>
|
||||
@ -134,6 +165,9 @@ export function UserKPICards({
|
||||
iconBgColor="bg-red-50"
|
||||
iconColor="text-red-600"
|
||||
testId="kpi-user-critical"
|
||||
onClick={() => onKPIClick({ ...getFilterParams(), targetPage: 'open-requests' })}
|
||||
onJustifyClick={handleNavigateToApproverPerformance}
|
||||
showJustifyButton={!!userId}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 mt-auto">
|
||||
<StatCard
|
||||
@ -142,6 +176,10 @@ export function UserKPICards({
|
||||
bgColor="bg-orange-50"
|
||||
textColor="text-red-600"
|
||||
testId="stat-user-breached"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), targetPage: 'open-requests' });
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Warning"
|
||||
@ -149,6 +187,10 @@ export function UserKPICards({
|
||||
bgColor="bg-yellow-50"
|
||||
textColor="text-orange-600"
|
||||
testId="stat-user-warning"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), targetPage: 'open-requests' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</KPICard>
|
||||
|
||||
@ -12,5 +12,6 @@ export interface KPIClickFilters {
|
||||
dateRange?: DateRange;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
targetPage?: 'requests' | 'open-requests' | 'my-requests';
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,8 @@
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { dashboardService, type DashboardKPIs, type DateRange, type AIRemarkUtilization, type ApproverPerformance, type DepartmentStats, type PriorityDistribution, type UpcomingDeadline, type RecentActivity, type CriticalRequest } from '@/services/dashboard.service';
|
||||
import { ActivityData, CriticalAlertData } from '@/components/dashboard/ActivityFeedItem';
|
||||
import { ActivityData } from '@/components/dashboard/ActivityFeedItem';
|
||||
import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
|
||||
|
||||
interface UseDashboardDataOptions {
|
||||
isAdmin: boolean;
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
* Dashboard-specific TypeScript types and interfaces
|
||||
*/
|
||||
|
||||
import { DateRange, DashboardKPIs, CriticalRequest, UpcomingDeadline, RecentActivity, DepartmentStats, PriorityDistribution, AIRemarkUtilization, ApproverPerformance } from '@/services/dashboard.service';
|
||||
import { ActivityData, CriticalAlertData } from '@/components/dashboard/ActivityFeedItem';
|
||||
import { DateRange, DashboardKPIs, CriticalRequest, UpcomingDeadline, DepartmentStats, PriorityDistribution, AIRemarkUtilization, ApproverPerformance } from '@/services/dashboard.service';
|
||||
import { ActivityData } from '@/components/dashboard/ActivityFeedItem';
|
||||
import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
|
||||
|
||||
export interface DashboardFilters {
|
||||
dateRange: DateRange;
|
||||
|
||||
@ -41,17 +41,25 @@ export function getUpcomingDeadlinesNotBreached(
|
||||
|
||||
/**
|
||||
* Format breach time for display
|
||||
* Backend returns working hours, so we divide by 8 (working hours per day) not 24
|
||||
*/
|
||||
export function formatBreachTime(hours: number): string {
|
||||
if (hours <= 0) return 'Just breached';
|
||||
if (hours < 1) return `${Math.round(hours * 60)} min`;
|
||||
if (hours < 24) {
|
||||
|
||||
const WORKING_HOURS_PER_DAY = 8;
|
||||
|
||||
// If less than one working day, show hours and minutes
|
||||
if (hours < WORKING_HOURS_PER_DAY) {
|
||||
const h = Math.floor(hours);
|
||||
const m = Math.round((hours - h) * 60);
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
const remainingHours = hours % 24;
|
||||
|
||||
// Convert working hours to working days
|
||||
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
||||
const remainingHours = hours % WORKING_HOURS_PER_DAY;
|
||||
|
||||
if (remainingHours > 0) {
|
||||
const h = Math.floor(remainingHours);
|
||||
const m = Math.round((remainingHours - h) * 60);
|
||||
|
||||
@ -16,6 +16,7 @@ export function buildFilterUrl(filters: {
|
||||
dateRange?: DateRange;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
targetPage?: 'requests' | 'open-requests' | 'my-requests';
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
@ -26,7 +27,14 @@ export function buildFilterUrl(filters: {
|
||||
if (filters.startDate) params.set('startDate', filters.startDate.toISOString());
|
||||
if (filters.endDate) params.set('endDate', filters.endDate.toISOString());
|
||||
const queryString = params.toString();
|
||||
return queryString ? `/requests?${queryString}` : '/requests';
|
||||
|
||||
// Determine target page
|
||||
const targetPage = filters.targetPage || 'requests';
|
||||
const basePath = targetPage === 'open-requests' ? '/open-requests'
|
||||
: targetPage === 'my-requests' ? '/my-requests'
|
||||
: '/requests';
|
||||
|
||||
return queryString ? `${basePath}?${queryString}` : basePath;
|
||||
}
|
||||
|
||||
export interface QuickAction {
|
||||
|
||||
@ -68,7 +68,7 @@ export function RequestLifecycleReport({
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg text-gray-900">Request Lifecycle Report</CardTitle>
|
||||
<CardDescription className="text-gray-600">End-to-end status with timeline and TAT compliance</CardDescription>
|
||||
<CardDescription className="text-gray-600">End-to-end workflow status including all approval levels, approvers, dates, and TAT compliance</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={onExport} disabled={exporting} data-testid="export-lifecycle-button">
|
||||
|
||||
@ -3,11 +3,12 @@
|
||||
*/
|
||||
|
||||
import { dashboardService, type DateRange } from '@/services/dashboard.service';
|
||||
import { formatTAT, formatDate, formatDateForCSV, mapActivityType } from './formatting';
|
||||
import { LifecycleRequest, ActivityLogEntry, AgingWorkflow } from '../types/detailedReports.types';
|
||||
import { getWorkflowDetails } from '@/services/workflowApi';
|
||||
import { formatDateForCSV, mapActivityType } from './formatting';
|
||||
|
||||
/**
|
||||
* Export Lifecycle Report to CSV
|
||||
* Export Lifecycle Report to CSV with End-to-End Workflow Details
|
||||
* Includes all approval levels, approvers, dates, and TAT compliance
|
||||
*/
|
||||
export async function exportLifecycleToCSV(
|
||||
dateRange: DateRange,
|
||||
@ -17,42 +18,198 @@ export async function exportLifecycleToCSV(
|
||||
// Fetch all data with a very large limit
|
||||
const result = await dashboardService.getLifecycleReport(1, 10000, dateRange, customStartDate, customEndDate);
|
||||
|
||||
// CSV Headers for comprehensive end-to-end workflow status
|
||||
const csvRows = [
|
||||
[
|
||||
'Request Number',
|
||||
'Title',
|
||||
'Priority',
|
||||
'Status',
|
||||
'Overall Status',
|
||||
'Initiator',
|
||||
'Submission Date',
|
||||
'Current Stage',
|
||||
'Overall TAT',
|
||||
'Current Level',
|
||||
'Closure Date',
|
||||
'Total Levels',
|
||||
'Current Level',
|
||||
'Overall TAT (Hours)',
|
||||
'Overall TAT (Days)',
|
||||
'Breach Count',
|
||||
'Level Number',
|
||||
'Level Name',
|
||||
'Approver Name',
|
||||
'Approver Email',
|
||||
'Level Status',
|
||||
'Level Start Date',
|
||||
'Level Completion Date',
|
||||
'Level TAT (Hours)',
|
||||
'Level TAT (Days)',
|
||||
'Level TAT Compliance',
|
||||
'Level Elapsed Hours',
|
||||
'Level Remaining Hours',
|
||||
'Level TAT % Used',
|
||||
].join(','),
|
||||
];
|
||||
|
||||
result.lifecycleData.forEach((req: any) => {
|
||||
const overallTAT = formatTAT(req.overallTATHours);
|
||||
const submissionDateCSV = req.submissionDate ? formatDateForCSV(req.submissionDate) : 'N/A';
|
||||
const row = [
|
||||
req.requestNumber || '',
|
||||
`"${(req.title || '').replace(/"/g, '""')}"`,
|
||||
req.priority || 'medium',
|
||||
req.status || '',
|
||||
`"${(req.initiatorName || 'Unknown').replace(/"/g, '""')}"`,
|
||||
submissionDateCSV,
|
||||
`"${(req.currentStageName || `Level ${req.currentLevel}`).replace(/"/g, '""')}"`,
|
||||
overallTAT,
|
||||
(req.currentLevel || '').toString(),
|
||||
(req.totalLevels || '').toString(),
|
||||
(req.breachCount || 0).toString(),
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
});
|
||||
// Process each request and fetch detailed approval level information
|
||||
for (const req of result.lifecycleData) {
|
||||
try {
|
||||
// Fetch detailed workflow information including all approval levels
|
||||
const workflowDetails = await getWorkflowDetails(req.requestId || req.requestNumber);
|
||||
|
||||
const submissionDateCSV = req.submissionDate ? formatDateForCSV(req.submissionDate) : 'N/A';
|
||||
const closureDateCSV = req.closureDate ? formatDateForCSV(req.closureDate) : 'N/A';
|
||||
const overallTATHours = req.overallTATHours || 0;
|
||||
const overallTATDays = overallTATHours > 0 ? (overallTATHours / 8).toFixed(2) : '0';
|
||||
|
||||
downloadCSV(csvRows, `lifecycle-report-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
// Fix: Ensure currentLevel doesn't exceed totalLevels (data inconsistency from backend)
|
||||
const totalLevels = req.totalLevels || 1;
|
||||
const currentLevel = Math.min(Math.max(1, req.currentLevel || 1), totalLevels);
|
||||
|
||||
// Get approval levels from workflow details
|
||||
const approvalLevels = workflowDetails?.approvals || [];
|
||||
|
||||
if (approvalLevels.length === 0) {
|
||||
// If no approval levels found, still export request-level data
|
||||
const row = [
|
||||
req.requestNumber || '',
|
||||
`"${(req.title || '').replace(/"/g, '""')}"`,
|
||||
req.priority || 'medium',
|
||||
req.status || '',
|
||||
`"${(req.initiatorName || 'Unknown').replace(/"/g, '""')}"`,
|
||||
submissionDateCSV,
|
||||
closureDateCSV,
|
||||
totalLevels.toString(),
|
||||
currentLevel.toString(),
|
||||
overallTATHours.toString(),
|
||||
overallTATDays,
|
||||
(req.breachCount || 0).toString(),
|
||||
'N/A', // Level Number
|
||||
'N/A', // Level Name
|
||||
'N/A', // Approver Name
|
||||
'N/A', // Approver Email
|
||||
'N/A', // Level Status
|
||||
'N/A', // Level Start Date
|
||||
'N/A', // Level Completion Date
|
||||
'N/A', // Level TAT Hours
|
||||
'N/A', // Level TAT Days
|
||||
'N/A', // Level TAT Compliance
|
||||
'N/A', // Level Elapsed Hours
|
||||
'N/A', // Level Remaining Hours
|
||||
'N/A', // Level TAT % Used
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
} else {
|
||||
// Export one row per approval level for comprehensive end-to-end view
|
||||
approvalLevels.forEach((level: any) => {
|
||||
const levelStartDateCSV = level.levelStartTime ? formatDateForCSV(level.levelStartTime) : 'N/A';
|
||||
const levelCompletionDateCSV = level.levelEndTime || level.completedAt
|
||||
? formatDateForCSV(level.levelEndTime || level.completedAt)
|
||||
: 'N/A';
|
||||
|
||||
const levelTATHours = level.tatHours || 0;
|
||||
const levelTATDays = level.tatDays || (levelTATHours > 0 ? (levelTATHours / 8).toFixed(2) : '0');
|
||||
|
||||
// Calculate TAT compliance status
|
||||
let tatCompliance = 'N/A';
|
||||
let elapsedHours = 'N/A';
|
||||
let remainingHours = 'N/A';
|
||||
let tatPercentageUsed = 'N/A';
|
||||
|
||||
if (level.levelStartTime) {
|
||||
const startTime = new Date(level.levelStartTime);
|
||||
const endTime = level.levelEndTime || level.completedAt
|
||||
? new Date(level.levelEndTime || level.completedAt)
|
||||
: new Date();
|
||||
const elapsedMs = endTime.getTime() - startTime.getTime();
|
||||
elapsedHours = (elapsedMs / (1000 * 60 * 60)).toFixed(2);
|
||||
|
||||
if (levelTATHours > 0) {
|
||||
const elapsed = parseFloat(elapsedHours);
|
||||
remainingHours = Math.max(0, levelTATHours - elapsed).toFixed(2);
|
||||
tatPercentageUsed = ((elapsed / levelTATHours) * 100).toFixed(2);
|
||||
|
||||
if (level.status === 'APPROVED' || level.status === 'REJECTED') {
|
||||
tatCompliance = elapsed <= levelTATHours ? 'Compliant' : 'Breached';
|
||||
} else if (level.status === 'IN_PROGRESS' || level.status === 'PENDING') {
|
||||
tatCompliance = elapsed <= levelTATHours ? 'On Track' : 'At Risk';
|
||||
} else {
|
||||
tatCompliance = 'N/A';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const row = [
|
||||
req.requestNumber || '',
|
||||
`"${(req.title || '').replace(/"/g, '""')}"`,
|
||||
req.priority || 'medium',
|
||||
req.status || '',
|
||||
`"${(req.initiatorName || 'Unknown').replace(/"/g, '""')}"`,
|
||||
submissionDateCSV,
|
||||
closureDateCSV,
|
||||
(req.totalLevels || 0).toString(),
|
||||
(req.currentLevel || 0).toString(),
|
||||
overallTATHours.toString(),
|
||||
overallTATDays,
|
||||
(req.breachCount || 0).toString(),
|
||||
(level.levelNumber || '').toString(),
|
||||
`"${(level.levelName || `Level ${level.levelNumber}`).replace(/"/g, '""')}"`,
|
||||
`"${(level.approverName || 'N/A').replace(/"/g, '""')}"`,
|
||||
`"${(level.approverEmail || 'N/A').replace(/"/g, '""')}"`,
|
||||
level.status || 'PENDING',
|
||||
levelStartDateCSV,
|
||||
levelCompletionDateCSV,
|
||||
levelTATHours.toString(),
|
||||
levelTATDays.toString(),
|
||||
tatCompliance,
|
||||
elapsedHours,
|
||||
remainingHours,
|
||||
tatPercentageUsed,
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch details for request ${req.requestNumber}:`, error);
|
||||
// Still export basic request info even if details fetch fails
|
||||
const submissionDateCSV = req.submissionDate ? formatDateForCSV(req.submissionDate) : 'N/A';
|
||||
const overallTATHours = req.overallTATHours || 0;
|
||||
const overallTATDays = overallTATHours > 0 ? (overallTATHours / 8).toFixed(2) : '0';
|
||||
|
||||
// Fix: Ensure currentLevel doesn't exceed totalLevels
|
||||
const totalLevels = req.totalLevels || 1;
|
||||
const currentLevel = Math.min(Math.max(1, req.currentLevel || 1), totalLevels);
|
||||
|
||||
const row = [
|
||||
req.requestNumber || '',
|
||||
`"${(req.title || '').replace(/"/g, '""')}"`,
|
||||
req.priority || 'medium',
|
||||
req.status || '',
|
||||
`"${(req.initiatorName || 'Unknown').replace(/"/g, '""')}"`,
|
||||
submissionDateCSV,
|
||||
'N/A',
|
||||
totalLevels.toString(),
|
||||
currentLevel.toString(),
|
||||
overallTATHours.toString(),
|
||||
overallTATDays,
|
||||
(req.breachCount || 0).toString(),
|
||||
'Error', // Level Number
|
||||
'Error fetching details', // Level Name
|
||||
'N/A', // Approver Name
|
||||
'N/A', // Approver Email
|
||||
'N/A', // Level Status
|
||||
'N/A', // Level Start Date
|
||||
'N/A', // Level Completion Date
|
||||
'N/A', // Level TAT Hours
|
||||
'N/A', // Level TAT Days
|
||||
'N/A', // Level TAT Compliance
|
||||
'N/A', // Level Elapsed Hours
|
||||
'N/A', // Level Remaining Hours
|
||||
'N/A', // Level TAT % Used
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
downloadCSV(csvRows, `lifecycle-report-end-to-end-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -9,6 +9,7 @@ import { ArrowRight, User, TrendingUp, Clock, FileText } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MyRequest } from '../types/myRequests.types';
|
||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
|
||||
interface RequestCardProps {
|
||||
request: MyRequest;
|
||||
@ -81,7 +82,7 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
</span>
|
||||
<span className="truncate" data-testid="submitted-date">
|
||||
<span className="font-medium">Submitted:</span>{' '}
|
||||
{request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
|
||||
{formatDateDDMMYYYY(request.submittedDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -109,7 +110,7 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span data-testid="submitted-timestamp">
|
||||
Submitted: {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
|
||||
Submitted: {formatDateDDMMYYYY(request.submittedDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,8 +47,6 @@ export function Notifications({ onNavigate }: NotificationsProps) {
|
||||
setNotifications(notifs);
|
||||
setTotalCount(total);
|
||||
setTotalPages(Math.ceil(total / ITEMS_PER_PAGE));
|
||||
|
||||
console.log(`[Notifications] Loaded page ${page}, ${notifs.length} notifications, ${total} total`);
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Failed to fetch:', error);
|
||||
} finally {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -6,9 +7,9 @@ import { Input } from '@/components/ui/input';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, X, CheckCircle, XCircle } from 'lucide-react';
|
||||
import workflowApi from '@/services/workflowApi';
|
||||
import { formatDateShort } from '@/utils/dateFormatter';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
interface Request {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -98,12 +99,18 @@ const getStatusConfig = (status: string) => {
|
||||
// getSLAUrgency removed - now using SLATracker component for real-time SLA display
|
||||
|
||||
export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'sla'>('created');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Initialize filters from URL params
|
||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
||||
const [priorityFilter, setPriorityFilter] = useState(searchParams.get('priority') || 'all');
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || 'all');
|
||||
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'sla'>(
|
||||
(searchParams.get('sortBy') as 'created' | 'due' | 'priority' | 'sla') || 'created'
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(
|
||||
(searchParams.get('sortOrder') as 'asc' | 'desc') || 'desc'
|
||||
);
|
||||
const [items, setItems] = useState<Request[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@ -140,15 +147,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
sortBy: filters?.sortBy,
|
||||
sortOrder: filters?.sortOrder
|
||||
});
|
||||
console.log('[OpenRequests] API Response:', result); // Debug log
|
||||
|
||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||
const data = Array.isArray((result as any)?.data)
|
||||
? (result as any).data
|
||||
: [];
|
||||
|
||||
console.log('[OpenRequests] Parsed data count:', data.length); // Debug log
|
||||
|
||||
// Set pagination data
|
||||
const pagination = (result as any)?.pagination;
|
||||
if (pagination) {
|
||||
@ -231,31 +235,39 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// Initial fetch on mount
|
||||
// Track if this is the initial mount
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
// Initial fetch on mount with URL params
|
||||
useEffect(() => {
|
||||
fetchRequests(1, {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
}, [fetchRequests]);
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
fetchRequests(1, {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only run on mount to use URL params
|
||||
|
||||
// Fetch when filters or sorting change (with debouncing for search)
|
||||
useEffect(() => {
|
||||
// Skip initial mount to avoid double fetch
|
||||
if (isInitialMount.current) return;
|
||||
|
||||
// Debounce search: wait 500ms after user stops typing
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (items.length > 0 || loading) { // Only refetch if we've already loaded data once
|
||||
setCurrentPage(1); // Reset to page 1 when filters change
|
||||
fetchRequests(1, {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
}
|
||||
setCurrentPage(1); // Reset to page 1 when filters change
|
||||
fetchRequests(1, {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
@ -330,28 +342,17 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{activeFiltersCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
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>
|
||||
)}
|
||||
{activeFiltersCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
className="gap-1 sm:gap-2 h-8 sm:h-9 px-2 sm:px-3"
|
||||
onClick={clearFilters}
|
||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span className="text-xs sm:text-sm hidden md:inline">{showAdvancedFilters ? 'Basic' : 'Advanced'}</span>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
||||
@ -545,7 +546,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>Created: {request.createdAt !== '—' ? formatDateShort(request.createdAt) : '—'}</span>
|
||||
<span>Created: {request.createdAt !== '—' ? formatDateDDMMYYYY(request.createdAt) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -621,7 +622,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
variant={pageNum === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
|
||||
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
|
||||
@ -165,7 +165,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const tabParam = urlParams.get('tab');
|
||||
if (tabParam) {
|
||||
console.log('[RequestDetail] Auto-switching to tab:', tabParam);
|
||||
setActiveTab(tabParam);
|
||||
}
|
||||
}, [requestIdentifier]);
|
||||
@ -227,47 +226,47 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="request-detail-tabs">
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0 mb-4 sm:mb-6">
|
||||
<TabsList className="inline-flex h-10 sm:h-11 items-center justify-start rounded-lg bg-gray-100 p-1 text-gray-500 min-w-max sm:w-full">
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<TabsList className="grid grid-cols-3 sm:grid-cols-5 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1 sm:gap-1.5 shrink-0"
|
||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||
data-testid="tab-overview"
|
||||
>
|
||||
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span>Overview</span>
|
||||
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="truncate">Overview</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="workflow"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1 sm:gap-1.5 shrink-0"
|
||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||
data-testid="tab-workflow"
|
||||
>
|
||||
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span>Workflow</span>
|
||||
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="truncate">Workflow</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="documents"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1 sm:gap-1.5 shrink-0"
|
||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||
data-testid="tab-documents"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span>Docs</span>
|
||||
<FileText className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="truncate">Docs</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="activity"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1 sm:gap-1.5 shrink-0"
|
||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 col-span-1 sm:col-span-1"
|
||||
data-testid="tab-activity"
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span>Activity</span>
|
||||
<Activity className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="truncate">Activity</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="worknotes"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1 sm:gap-1.5 relative shrink-0"
|
||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 relative col-span-2 sm:col-span-1"
|
||||
data-testid="tab-worknotes"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span>Work Notes</span>
|
||||
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="truncate">Work Notes</span>
|
||||
{unreadWorkNotes > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-[10px] flex items-center justify-center p-0"
|
||||
@ -339,6 +338,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
mergedMessages={mergedMessages}
|
||||
setWorkNoteAttachments={setWorkNoteAttachments}
|
||||
isInitiator={isInitiator}
|
||||
isSpectator={isSpectator}
|
||||
currentLevels={currentLevels}
|
||||
onAddApprover={handleAddApprover}
|
||||
/>
|
||||
|
||||
@ -30,64 +30,66 @@ export function QuickActionsSidebar({
|
||||
}: QuickActionsSidebarProps) {
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Quick Actions Card */}
|
||||
<Card data-testid="quick-actions-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* Add Approver */}
|
||||
{isInitiator && request.status !== 'closed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm"
|
||||
onClick={onAddApprover}
|
||||
data-testid="add-approver-button"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
Add Approver
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Add Spectator */}
|
||||
{!isSpectator && request.status !== 'closed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm"
|
||||
onClick={onAddSpectator}
|
||||
data-testid="add-spectator-button"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
Add Spectator
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Approve/Reject Buttons */}
|
||||
<div className="pt-3 sm:pt-4 space-y-2">
|
||||
{!isSpectator && currentApprovalLevel && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white h-9 sm:h-10 text-xs sm:text-sm"
|
||||
onClick={onApprove}
|
||||
data-testid="approve-request-button"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
|
||||
Approve Request
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full h-9 sm:h-10 text-xs sm:text-sm"
|
||||
onClick={onReject}
|
||||
data-testid="reject-request-button"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
|
||||
Reject Request
|
||||
</Button>
|
||||
</>
|
||||
{/* Quick Actions Card - Hide entire card for spectators */}
|
||||
{!isSpectator && (
|
||||
<Card data-testid="quick-actions-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* Add Approver */}
|
||||
{isInitiator && request.status !== 'closed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm"
|
||||
onClick={onAddApprover}
|
||||
data-testid="add-approver-button"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
Add Approver
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Spectator */}
|
||||
{request.status !== 'closed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm"
|
||||
onClick={onAddSpectator}
|
||||
data-testid="add-spectator-button"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
Add Spectator
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Approve/Reject Buttons */}
|
||||
<div className="pt-3 sm:pt-4 space-y-2">
|
||||
{currentApprovalLevel && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white h-9 sm:h-10 text-xs sm:text-sm"
|
||||
onClick={onApprove}
|
||||
data-testid="approve-request-button"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
|
||||
Approve Request
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full h-9 sm:h-10 text-xs sm:text-sm"
|
||||
onClick={onReject}
|
||||
data-testid="reject-request-button"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
|
||||
Reject Request
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Spectators Card */}
|
||||
{request.spectators && request.spectators.length > 0 && (
|
||||
|
||||
@ -24,7 +24,7 @@ interface OverviewTabProps {
|
||||
|
||||
export function OverviewTab({
|
||||
request,
|
||||
isInitiator,
|
||||
isInitiator: _isInitiator,
|
||||
needsClosure,
|
||||
conclusionRemark,
|
||||
setConclusionRemark,
|
||||
|
||||
@ -10,6 +10,7 @@ interface WorkNotesTabProps {
|
||||
mergedMessages: any[];
|
||||
setWorkNoteAttachments: (attachments: any[]) => void;
|
||||
isInitiator: boolean;
|
||||
isSpectator: boolean;
|
||||
currentLevels: any[];
|
||||
onAddApprover: (email: string, tatHours: number, level: number) => Promise<void>;
|
||||
}
|
||||
@ -20,6 +21,7 @@ export function WorkNotesTab({
|
||||
mergedMessages,
|
||||
setWorkNoteAttachments,
|
||||
isInitiator,
|
||||
isSpectator,
|
||||
currentLevels,
|
||||
onAddApprover,
|
||||
}: WorkNotesTabProps) {
|
||||
@ -32,6 +34,7 @@ export function WorkNotesTab({
|
||||
messages={mergedMessages}
|
||||
onAttachmentsExtracted={setWorkNoteAttachments}
|
||||
isInitiator={isInitiator}
|
||||
isSpectator={isSpectator}
|
||||
currentLevels={currentLevels}
|
||||
onAddApprover={onAddApprover}
|
||||
/>
|
||||
|
||||
@ -30,10 +30,51 @@ export function WorkflowTab({ request, user, isInitiator, onSkipApprover, onRefr
|
||||
</CardDescription>
|
||||
</div>
|
||||
{request.totalSteps && (() => {
|
||||
const completedCount = request.approvalFlow?.filter((s: any) => s.status === 'approved').length || 0;
|
||||
const totalSteps = request.totalSteps || 1;
|
||||
// Use raw value if available (for completion detection), otherwise use clamped currentStep
|
||||
// Backend sets currentLevel = levelNumber + 1 when final approver approves (closure step)
|
||||
const rawCurrentStep = request.currentStepRaw !== undefined ? request.currentStepRaw : (request.currentStep || 1);
|
||||
|
||||
// Count completed steps (approved or rejected)
|
||||
const completedCount = request.approvalFlow?.filter((s: any) => {
|
||||
const status = (s.status || '').toLowerCase();
|
||||
return status === 'approved' || status === 'rejected';
|
||||
}).length || 0;
|
||||
|
||||
const allStepsCompleted = completedCount >= totalSteps;
|
||||
const requestStatus = (request.status || '').toLowerCase();
|
||||
const isRequestCompleted = requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed';
|
||||
|
||||
// Backend sets currentLevel = levelNumber + 1 when final approver approves (closure step)
|
||||
// So if currentStep > totalSteps, it means the workflow is in closure phase
|
||||
const isInClosureStep = rawCurrentStep > totalSteps;
|
||||
|
||||
// Ensure currentStep is valid: between 1 and totalSteps (for display purposes)
|
||||
const currentStep = Math.min(Math.max(1, rawCurrentStep), totalSteps);
|
||||
|
||||
// Show "Closure Step" when currentStep > totalSteps (workflow is in closure phase)
|
||||
if (isInClosureStep) {
|
||||
return (
|
||||
<Badge variant="outline" className="font-medium text-xs sm:text-sm shrink-0 bg-blue-50 text-blue-700 border-blue-200">
|
||||
Closure Step - {completedCount} of {totalSteps} steps completed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Show "Completed" when:
|
||||
// 1. Request status is completed (approved/rejected/closed)
|
||||
// 2. All steps are approved/rejected (completedCount >= totalSteps)
|
||||
if (isRequestCompleted || allStepsCompleted) {
|
||||
return (
|
||||
<Badge variant="outline" className="font-medium text-xs sm:text-sm shrink-0 bg-green-50 text-green-700 border-green-200">
|
||||
Completed - {completedCount} of {totalSteps} steps
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="font-medium text-xs sm:text-sm shrink-0">
|
||||
Step {request.currentStep} of {request.totalSteps} - {completedCount} completed
|
||||
Step {currentStep} of {totalSteps} - {completedCount} completed
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { User, ArrowRight, TrendingUp, Clock } from 'lucide-react';
|
||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
import type { ConvertedRequest } from '../types/requests.types';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
|
||||
interface RequestCardProps {
|
||||
request: ConvertedRequest;
|
||||
@ -81,7 +82,7 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
<span className="font-medium">ID:</span> {request.displayId || request.id}
|
||||
</span>
|
||||
<span className="truncate" data-testid="submitted-date">
|
||||
<span className="font-medium">Submitted:</span> {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
|
||||
<span className="font-medium">Submitted:</span> {formatDateDDMMYYYY(request.submittedDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -107,7 +108,7 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span data-testid="submitted-timestamp">
|
||||
Submitted: {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
|
||||
Submitted: {formatDateDDMMYYYY(request.submittedDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -39,7 +39,17 @@ export interface Holiday {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all admin configurations
|
||||
* Get public configurations (accessible to all authenticated users)
|
||||
* Returns non-sensitive configurations like DOCUMENT_POLICY, WORKFLOW_SHARING, TAT_SETTINGS
|
||||
*/
|
||||
export const getPublicConfigurations = async (category?: string): Promise<AdminConfiguration[]> => {
|
||||
const params = category ? { category } : {};
|
||||
const response = await apiClient.get('/users/configurations', { params });
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all admin configurations (admin only)
|
||||
*/
|
||||
export const getAllConfigurations = async (category?: string): Promise<AdminConfiguration[]> => {
|
||||
const params = category ? { category } : {};
|
||||
|
||||
@ -100,12 +100,6 @@ export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<TokenExchangeResponse> {
|
||||
console.log('🔄 Exchange Code for Tokens:', {
|
||||
code: code ? `${code.substring(0, 10)}...` : 'MISSING',
|
||||
redirectUri,
|
||||
endpoint: `${API_BASE_URL}/auth/token-exchange`,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiClient.post<TokenExchangeResponse>(
|
||||
'/auth/token-exchange',
|
||||
@ -122,21 +116,6 @@ export async function exchangeCodeForTokens(
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✅ Token exchange successful', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
contentType: response.headers['content-type'],
|
||||
hasData: !!response.data,
|
||||
dataType: typeof response.data,
|
||||
dataIsArray: Array.isArray(response.data),
|
||||
dataPreview: Array.isArray(response.data)
|
||||
? `Array[${response.data.length}]`
|
||||
: typeof response.data === 'object'
|
||||
? JSON.stringify(response.data).substring(0, 100)
|
||||
: String(response.data).substring(0, 100),
|
||||
});
|
||||
|
||||
// Check if response is an array (buffer issue)
|
||||
if (Array.isArray(response.data)) {
|
||||
console.error('❌ Response is an array (buffer issue):', {
|
||||
@ -158,9 +137,7 @@ export async function exchangeCodeForTokens(
|
||||
// Store id_token if available (needed for proper Okta logout)
|
||||
if (result.idToken) {
|
||||
TokenManager.setIdToken(result.idToken);
|
||||
console.log('✅ ID token stored for logout');
|
||||
}
|
||||
console.log('✅ Tokens stored successfully');
|
||||
} else {
|
||||
console.warn('⚠️ Tokens missing in response', { result });
|
||||
}
|
||||
@ -219,13 +196,10 @@ export async function getCurrentUser() {
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
console.log('📡 Calling backend logout endpoint to clear httpOnly cookies...');
|
||||
// Use withCredentials to ensure cookies are sent
|
||||
const response = await apiClient.post('/auth/logout', {}, {
|
||||
await apiClient.post('/auth/logout', {}, {
|
||||
withCredentials: true, // Ensure cookies are sent with request
|
||||
});
|
||||
console.log('📡 Backend logout response:', response.status, response.statusText);
|
||||
console.log('📡 Response headers (check Set-Cookie):', response.headers);
|
||||
} catch (error: any) {
|
||||
console.error('📡 Logout API error:', error);
|
||||
console.error('📡 Error details:', {
|
||||
|
||||
@ -141,8 +141,6 @@ class ConfigService {
|
||||
const response = await apiClient.get('/config');
|
||||
const serverConfig = response.data?.data || response.data;
|
||||
|
||||
console.log('[ConfigService] ✅ Loaded system configuration from server:', serverConfig);
|
||||
|
||||
// Merge with defaults (in case server doesn't return all fields)
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
|
||||
@ -693,6 +693,16 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(203 213 225) transparent;
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@ -102,3 +102,44 @@ export function formatTimeOnly(dateString: string | Date | null | undefined): st
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date with day first, then month (mmm), then year (e.g., "15 Nov 2025, 3:47:28 PM")
|
||||
* Format: dd mmm yyyy (day first, then month name, then year)
|
||||
* @param dateString - ISO date string or Date object
|
||||
* @param includeTime - Whether to include time in the format
|
||||
* @returns Formatted date string with day first
|
||||
*/
|
||||
export function formatDateDDMMYYYY(dateString: string | Date | null | undefined, includeTime: boolean = false): string {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
try {
|
||||
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
// Always put day first, then month (mmm format), then year
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const month = monthNames[date.getMonth()]; // Use month name (mmm)
|
||||
const year = date.getFullYear();
|
||||
|
||||
if (includeTime) {
|
||||
const hours24 = date.getHours();
|
||||
const hours12 = hours24 % 12 || 12;
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
const ampm = hours24 >= 12 ? 'PM' : 'AM';
|
||||
// Format: dd mmm yyyy, hh:mm:ss AM/PM (day first, then month, then year)
|
||||
return `${day} ${month} ${year}, ${hours12}:${minutes}:${seconds} ${ampm}`;
|
||||
}
|
||||
|
||||
// Format: dd mmm yyyy (day first, then month, then year)
|
||||
return `${day} ${month} ${year}`;
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', error);
|
||||
return String(dateString);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,6 @@ async function ensureConfigLoaded() {
|
||||
WORK_START_DAY = config.workingHours.START_DAY;
|
||||
WORK_END_DAY = config.workingHours.END_DAY;
|
||||
configLoaded = true;
|
||||
console.log('[SLA Tracker] ✅ Loaded working hours from backend:', { WORK_START_HOUR, WORK_END_HOUR });
|
||||
} catch (error) {
|
||||
console.warn('[SLA Tracker] ⚠️ Using default working hours (9 AM - 6 PM)');
|
||||
}
|
||||
@ -223,23 +222,51 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
|
||||
}
|
||||
|
||||
/**
|
||||
* Format decimal hours to hours and minutes format (e.g., 2.6 -> "2h 23m")
|
||||
* Simple format without considering working days
|
||||
* Format decimal hours to hours and minutes format
|
||||
* Considers 8 working hours = 1 day (consistent with TAT calculations and backend formatTime)
|
||||
* Uses "hour" (singular) for 1 hour, "hours" (plural) for multiple hours
|
||||
* Matches backend formatTime format from tatTimeUtils.ts for consistency
|
||||
* Examples: 1 -> "1 hour", 8 -> "1 day", 9 -> "1 day 1 hour", 16 -> "2 days"
|
||||
*/
|
||||
export function formatHoursMinutes(hours: number | null | undefined): string {
|
||||
if (hours === null || hours === undefined || hours < 0) return '0h';
|
||||
if (hours === 0) return '0h';
|
||||
if (hours === null || hours === undefined || hours < 0) return '0 hours';
|
||||
if (hours === 0) return '0 hours';
|
||||
|
||||
const totalMinutes = Math.round(hours * 60);
|
||||
const h = Math.floor(totalMinutes / 60);
|
||||
const m = totalMinutes % 60;
|
||||
const WORKING_HOURS_PER_DAY = 8;
|
||||
|
||||
if (h > 0 && m > 0) {
|
||||
return `${h}h ${m}m`;
|
||||
} else if (h > 0) {
|
||||
return `${h}h`;
|
||||
// If less than 1 hour, show minutes only
|
||||
if (hours < 1) {
|
||||
const m = Math.round(hours * 60);
|
||||
return m > 0 ? `${m}m` : '0 hours';
|
||||
}
|
||||
|
||||
// Calculate days and remaining hours (8 hours = 1 day)
|
||||
// Match backend formatTime logic from Re_Backend/src/utils/tatTimeUtils.ts
|
||||
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
||||
const remainingHrs = Math.floor(hours % WORKING_HOURS_PER_DAY);
|
||||
const minutes = Math.round((hours % 1) * 60);
|
||||
|
||||
// If we have days, format with days (matching backend format)
|
||||
if (days > 0) {
|
||||
const dayLabel = days === 1 ? 'day' : 'days';
|
||||
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
|
||||
const minuteLabel = minutes === 1 ? 'min' : 'm';
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
|
||||
} else {
|
||||
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// No days, just hours and minutes
|
||||
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
|
||||
const minuteLabel = minutes === 1 ? 'min' : 'm';
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
|
||||
} else {
|
||||
return `${m}m`;
|
||||
return `${remainingHrs} ${hourLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,8 +29,6 @@ export function getSocket(baseUrl?: string): Socket {
|
||||
const url = baseUrl || getSocketBaseUrl();
|
||||
if (socket) return socket;
|
||||
|
||||
console.log('[Socket] Connecting to:', url);
|
||||
|
||||
socket = io(url, {
|
||||
withCredentials: true,
|
||||
transports: ['websocket', 'polling'],
|
||||
@ -41,15 +39,15 @@ export function getSocket(baseUrl?: string): Socket {
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[Socket] Connected successfully:', socket?.id);
|
||||
// Socket connected
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[Socket] Connection error:', error.message);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('[Socket] Disconnected:', reason);
|
||||
socket.on('disconnect', (_reason) => {
|
||||
// Socket disconnected
|
||||
});
|
||||
|
||||
return socket;
|
||||
@ -69,7 +67,6 @@ export function leaveRequestRoom(socket: Socket, requestId: string) {
|
||||
|
||||
export function joinUserRoom(socket: Socket, userId: string) {
|
||||
socket.emit('join:user', { userId });
|
||||
console.log('[Socket] Joined personal notification room for user:', userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -172,8 +172,6 @@ export class TokenManager {
|
||||
* IMPORTANT: This also sets a flag to prevent auto-authentication
|
||||
*/
|
||||
static clearAll(): void {
|
||||
console.log('TokenManager.clearAll() - Starting cleanup...');
|
||||
|
||||
// CRITICAL: Set logout flag in sessionStorage FIRST (before clearing)
|
||||
// This flag survives the redirect and prevents auto-authentication
|
||||
try {
|
||||
@ -212,7 +210,6 @@ export class TokenManager {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
console.log(`Removed ${key} from storage`);
|
||||
} catch (e) {
|
||||
console.warn(`Error removing ${key}:`, e);
|
||||
}
|
||||
@ -233,7 +230,6 @@ export class TokenManager {
|
||||
allLocalStorageKeys.forEach(key => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
console.log(`Removed localStorage key: ${key}`);
|
||||
} catch (e) {
|
||||
console.warn(`Error removing localStorage key ${key}:`, e);
|
||||
}
|
||||
@ -241,7 +237,6 @@ export class TokenManager {
|
||||
|
||||
// Final clear as backup
|
||||
localStorage.clear();
|
||||
console.log('localStorage.clear() called');
|
||||
} catch (e) {
|
||||
console.error('Error clearing localStorage:', e);
|
||||
}
|
||||
@ -261,7 +256,6 @@ export class TokenManager {
|
||||
allSessionStorageKeys.forEach(key => {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
console.log(`Removed sessionStorage key: ${key}`);
|
||||
} catch (e) {
|
||||
console.warn(`Error removing sessionStorage key ${key}:`, e);
|
||||
}
|
||||
@ -269,7 +263,6 @@ export class TokenManager {
|
||||
|
||||
// Final clear as backup
|
||||
sessionStorage.clear();
|
||||
console.log('sessionStorage.clear() called');
|
||||
} catch (e) {
|
||||
console.error('Error clearing sessionStorage:', e);
|
||||
}
|
||||
@ -328,23 +321,12 @@ export class TokenManager {
|
||||
});
|
||||
});
|
||||
|
||||
// Log remaining cookies (httpOnly cookies will still show here as they can't be cleared from JS)
|
||||
console.log('⚠️ Remaining cookies (httpOnly cookies cannot be cleared from JavaScript):', document.cookie);
|
||||
console.log('⚠️ httpOnly cookies can only be cleared by backend - backend logout endpoint should handle this');
|
||||
// Note: httpOnly cookies can only be cleared by backend - backend logout endpoint should handle this
|
||||
|
||||
// Step 6: Verify cleanup
|
||||
console.log('TokenManager.clearAll() - Verification:');
|
||||
console.log(`localStorage length: ${localStorage.length}`);
|
||||
console.log(`sessionStorage length: ${sessionStorage.length}`);
|
||||
console.log(`Cookies: ${document.cookie}`);
|
||||
|
||||
if (localStorage.length > 0 || sessionStorage.length > 0) {
|
||||
console.warn('WARNING: Storage not fully cleared!');
|
||||
console.log('Remaining localStorage keys:', Object.keys(localStorage));
|
||||
console.log('Remaining sessionStorage keys:', Object.keys(sessionStorage));
|
||||
}
|
||||
|
||||
console.log('TokenManager.clearAll() - Cleanup complete');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -193,5 +193,8 @@ export default defineConfig({
|
||||
preserveSymlinks: false,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user