Compare commits

..

No commits in common. "a4662c99f3e0fe098f68ca80db8f6d2166e21886" and "6d6b2a3f9ccabfb4c4a2db4fa994e88e09f1db51" have entirely different histories.

63 changed files with 1157 additions and 7180 deletions

View File

@ -3,7 +3,3 @@ VITE_BASE_URL={{BACKEND_BASE_URL}}
VITE_API_BASE_URL={{BACKEND_BASEURL+api/v1}} VITE_API_BASE_URL={{BACKEND_BASEURL+api/v1}}
VITE_OKTA_CLIENT_ID={{Client_id_given_by client for respective mode (UAT/DEVELOPMENT)) VITE_OKTA_CLIENT_ID={{Client_id_given_by client for respective mode (UAT/DEVELOPMENT))
VITE_OKTA_DOMAIN={{OKTA_DOMAIN}} VITE_OKTA_DOMAIN={{OKTA_DOMAIN}}
# Tanflow (Dealer login) base URL and client ID; client secret is in backend .env only
VITE_TANFLOW_BASE_URL={{TANFLOW_BASE_URL}}
VITE_TANFLOW_CLIENT_ID={{TANFLOW_CLIENT_ID}}

741
README.md
View File

@ -1,93 +1,716 @@
# RE-Workflow-FE # 🏍️ Royal Enfield Approval Portal
A modern, enterprise-grade approval and request management system built with React, TypeScript, and Tailwind CSS.
## 📋 Table of Contents
## Getting started - [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)
To make it easy for you to get started with GitLab, here's a list of recommended next steps. ## ✨ Features
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! ### 🔄 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
## Add your files ### 📊 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)
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files ### 🎯 Request Management
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: - 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
cd existing_repo - **User Management** - Search, assign roles (USER, MANAGEMENT, ADMIN), and manage user permissions
git remote add origin http://10.10.1.3:2010/pradeep.jha/re-workflow-fe.git - **User Role Management** - Assign and manage user roles with Okta integration
git branch -M main - **System Configuration** - Comprehensive admin settings:
git push -uf origin main - **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
## Integrate with your tools ### 📈 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
- [ ] [Set up project integrations](http://10.10.1.3:2010/pradeep.jha/re-workflow-fe/-/settings/integrations) ### 💬 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
## Collaborate with your team ### 🔔 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
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) ### 🎨 Modern UI/UX
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) - Responsive design (mobile, tablet, desktop)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) - Dark mode support
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) - Accessible components (WCAG compliant)
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) - Royal Enfield brand theming
- Smooth animations and transitions
- Intuitive navigation and user flows
## Test and Deploy ## 🛠️ Tech Stack
Use the built-in continuous integration in GitLab. - **Framework:** React 18.3+
- **Language:** TypeScript 5.6+
- **Build Tool:** Vite 5.4+
- **Styling:** Tailwind CSS 3.4+
- **UI Components:** shadcn/ui + Radix UI
- **Icons:** Lucide React
- **Notifications:** Sonner (Toast) + Web Push API (VAPID)
- **Real-Time Communication:** Socket.IO Client
- **State Management:** React Hooks (useState, useMemo, useContext)
- **Authentication:** Okta SSO Integration
- **HTTP Client:** Axios
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) ## 📦 Prerequisites
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
*** - **Node.js:** >= 18.0.0
- **npm:** >= 9.0.0 (or yarn/pnpm)
- **Git:** Latest version
# Editing this README ## 🚀 Installation
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. ### Quick Start Checklist
## Suggestions for a good README - [ ] Clone the repository
- [ ] Install Node.js (>= 18.0.0) and npm (>= 9.0.0)
- [ ] Install project dependencies
- [ ] Set up environment variables (`.env.local`)
- [ ] Ensure backend API is running (optional for initial setup)
- [ ] Start development server
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. ### 1. Clone the repository
## Name \`\`\`bash
Choose a self-explaining name for your project. git clone <repository-url>
cd Re_Frontend_Code
\`\`\`
## Description ### 2. Install dependencies
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges \`\`\`bash
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. npm install
\`\`\`
## Visuals ### 3. Set up environment variables
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation #### Option A: Automated Setup (Recommended - Unix/Linux/Mac)
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage Run the setup script to automatically create environment files:
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support \`\`\`bash
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. chmod +x setup-env.sh
./setup-env.sh
\`\`\`
## Roadmap This script will:
If you have ideas for releases in the future, it is a good idea to list them in the README. - Create `.env.example` with all required variables
- Create `.env.local` for local development
- Create `.env.production` with your production configuration (interactive)
## Contributing #### Option B: Manual Setup (Windows or Custom Configuration)
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. **For Windows (PowerShell):**
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. 1. Create `.env.local` file in the project root:
## Authors and acknowledgment \`\`\`powershell
Show your appreciation to those who have contributed to the project. # Create .env.local file
New-Item -Path .env.local -ItemType File
\`\`\`
## License 2. Add the following content to `.env.local`:
For open source projects, say how it is licensed.
\`\`\`env
# Local Development Environment
VITE_API_BASE_URL=http://localhost:5000/api/v1
VITE_BASE_URL=http://localhost:5000
# Okta Authentication Configuration
VITE_OKTA_DOMAIN=your-okta-domain.okta.com
VITE_OKTA_CLIENT_ID=your-okta-client-id
# Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=your-vapid-public-key
\`\`\`
**For Production:**
Create `.env.production` with production values:
\`\`\`env
# Production Environment
VITE_API_BASE_URL=https://your-backend-url.com/api/v1
VITE_BASE_URL=https://your-backend-url.com
# Okta Authentication Configuration
VITE_OKTA_DOMAIN=https://your-org.okta.com
VITE_OKTA_CLIENT_ID=your-production-client-id
# Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=your-production-vapid-key
\`\`\`
#### Environment Variables Reference
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
| `VITE_API_BASE_URL` | Backend API base URL (with `/api/v1`) | Yes | `http://localhost:5000/api/v1` |
| `VITE_BASE_URL` | Base URL for WebSocket and direct file access (without `/api/v1`) | Yes | `http://localhost:5000` |
| `VITE_OKTA_DOMAIN` | Okta domain for SSO authentication | Yes* | - |
| `VITE_OKTA_CLIENT_ID` | Okta client ID for authentication | Yes* | - |
| `VITE_PUBLIC_VAPID_KEY` | Public VAPID key for web push notifications | No | - |
**Notes:**
- `VITE_BASE_URL` is used for WebSocket connections and must point to the base backend URL (not `/api/v1`)
- `VITE_PUBLIC_VAPID_KEY` is required for web push notifications. Generate using:
\`\`\`bash
npm install -g web-push
web-push generate-vapid-keys
\`\`\`
Use the **public key** in the frontend `.env.local` file
\*Required if using Okta authentication
### 4. Verify setup
Check that all required files exist:
\`\`\`bash
# Check environment file exists
ls -la .env.local # Unix/Linux/Mac
# or
Test-Path .env.local # Windows PowerShell
\`\`\`
## 💻 Development
### Prerequisites
Before starting development, ensure:
1. **Backend API is running:**
- The backend should be running on `http://localhost:5000` (or your configured URL)
- Backend API should be accessible at `/api/v1` endpoint
- CORS should be configured to allow your frontend origin
2. **Environment variables are configured:**
- `.env.local` file exists and contains valid configuration
- All required variables are set (see [Environment Variables Reference](#environment-variables-reference))
3. **Node.js and npm versions:**
- Verify Node.js version: `node --version` (should be >= 18.0.0)
- Verify npm version: `npm --version` (should be >= 9.0.0)
### Start development server
\`\`\`bash
npm run dev
\`\`\`
The application will open at `http://localhost:5173` (Vite default port)
> **Note:** If port 5173 is in use, Vite will automatically use the next available port.
### Build for production
\`\`\`bash
npm run build
\`\`\`
### Preview production build
\`\`\`bash
npm run preview
\`\`\`
## 📁 Project Structure
\`\`\`
Re_Figma_Code/
├── src/
│ ├── components/
│ │ ├── ui/ # Reusable UI components (40+)
│ │ ├── admin/ # Admin components
│ │ │ ├── AIConfig/ # AI configuration
│ │ │ ├── AnalyticsConfig/ # Analytics settings
│ │ │ ├── DashboardConfig/ # Dashboard customization
│ │ │ ├── DocumentConfig/ # Document policies
│ │ │ ├── NotificationConfig/ # Notification settings
│ │ │ ├── SharingConfig/ # Sharing policies
│ │ │ ├── TATConfig/ # TAT configuration
│ │ │ ├── UserManagement/ # User management
│ │ │ └── UserRoleManager/ # Role assignment
│ │ ├── approval/ # Approval workflow components
│ │ ├── common/ # Common reusable components
│ │ ├── dashboard/ # Dashboard widgets
│ │ ├── modals/ # Modal components
│ │ ├── participant/ # Participant management
│ │ ├── workflow/ # Workflow components
│ │ └── workNote/ # Work notes/chat components
│ ├── pages/
│ │ ├── Admin/ # Admin control panel
│ │ ├── ApproverPerformance/ # Approver analytics
│ │ ├── Auth/ # Authentication pages
│ │ ├── Dashboard/ # Main dashboard
│ │ ├── RequestDetail/ # Request detail view
│ │ ├── Requests/ # Request listing
│ │ └── ...
│ ├── hooks/ # Custom React hooks
│ │ ├── useRequestSocket.ts # WebSocket integration
│ │ ├── useDocumentUpload.ts # Document management
│ │ ├── useSLATracking.ts # SLA tracking
│ │ └── ...
│ ├── services/ # API services
│ │ ├── adminApi.ts # Admin API calls
│ │ ├── authApi.ts # Authentication API
│ │ ├── workflowApi.ts # Workflow API
│ │ └── ...
│ ├── utils/
│ │ ├── 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
│ ├── App.tsx
│ └── main.tsx
├── public/
│ └── service-worker.js # Service worker for push notifications
├── .vscode/
├── index.html
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.ts
├── postcss.config.js
├── eslint.config.js
├── .prettierrc
├── .gitignore
├── package.json
└── README.md
\`\`\`
## 📜 Available Scripts
| Script | Description |
|--------|-------------|
| `npm run dev` | Start development server |
| `npm run build` | Build for production |
| `npm run preview` | Preview production build |
| `npm run lint` | Run ESLint |
| `npm run lint:fix` | Fix ESLint errors |
| `npm run format` | Format code with Prettier |
| `npm run type-check` | Check TypeScript types |
## ⚙️ Configuration
### TypeScript Path Aliases
The project uses path aliases for cleaner imports:
\`\`\`typescript
import { Button } from '@/components/ui/button';
import { getSocket } from '@/utils/socket';
\`\`\`
Path aliases are configured in:
- `tsconfig.json` - TypeScript path mapping
- `vite.config.ts` - Vite resolver configuration
### Tailwind CSS Customization
Custom Royal Enfield colors are defined in `tailwind.config.ts`:
\`\`\`typescript
colors: {
're-green': '#2d4a3e',
're-gold': '#c9b037',
're-dark': '#1a1a1a',
're-light-green': '#8a9b8e',
}
\`\`\`
### Environment Variables
All environment variables must be prefixed with `VITE_` to be accessible in the app:
\`\`\`typescript
// Access environment variables
const apiUrl = import.meta.env.VITE_API_BASE_URL;
const baseUrl = import.meta.env.VITE_BASE_URL;
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN;
\`\`\`
**Important Notes:**
- Environment variables are embedded at build time, not runtime
- Changes to `.env` files require restarting the dev server
- `.env.local` takes precedence over `.env` in development
- `.env.production` is used when building for production (`npm run build`)
### Backend Integration
To connect to the backend API:
1. **Update API base URL** in `.env.local`:
\`\`\`env
VITE_API_BASE_URL=http://localhost:5000/api/v1
VITE_BASE_URL=http://localhost:5000
\`\`\`
2. **Configure CORS** in your backend to allow your frontend origin
3. **Authentication:**
- Configure Okta credentials in environment variables
- Ensure backend validates JWT tokens from Okta
- Backend should handle token exchange and refresh
4. **WebSocket Configuration:**
- Backend must support Socket.IO on the base URL
- Socket.IO path: `/socket.io`
- CORS must allow WebSocket connections
- Events: `worknote:new`, `presence:join`, `presence:leave`, `request:online-users`
5. **Web Push Notifications:**
- Backend must support VAPID push notification delivery
- Service worker registration endpoint required
- Push subscription management API needed
- Generate VAPID keys using: `npm install -g web-push && web-push generate-vapid-keys`
6. **API Services:**
- API services are located in `src/services/`
- All API calls use `axios` configured with base URL from environment
- WebSocket utilities in `src/utils/socket.ts`
### Development vs Production
- **Development:** Uses `.env.local` (git-ignored)
- **Production:** Uses `.env.production` or environment variables set in deployment platform
- **Never commit:** `.env.local` or `.env.production` (use `.env.example` as template)
## 🚀 Key Features Deep Dive
### 👥 Admin Control Panel
The Admin Control Panel (`/admin`) provides comprehensive system management capabilities accessible only to ADMIN role users:
#### User Management
- **User Search**: Search users via Okta integration with real-time search
- **Role Assignment**: Assign and manage user roles (USER, MANAGEMENT, ADMIN)
- **User Statistics**: View role distribution and user counts
- **Pagination**: Efficient pagination for large user lists
- **Filtering**: Filter users by role (ELEVATED, ADMIN, MANAGEMENT, USER, ALL)
#### System Configuration Tabs
1. **KPI Configuration**
- Configure dashboard KPIs with visibility settings per role
- Set alert thresholds and breach conditions
- Organize KPIs by categories (Request Volume, TAT Efficiency, Approver Load, etc.)
- Enable/disable KPIs dynamically
2. **Analytics Configuration**
- Data retention policies
- Export format settings (CSV, Excel, PDF)
- Analytics feature toggles
- Data collection preferences
3. **TAT Configuration**
- Working hours configuration (start/end time, days of week)
- Priority-based TAT settings (express, standard, urgent)
- Escalation rules and thresholds
- Holiday calendar integration
4. **Notification Configuration**
- Email template customization
- Notification channel management (email, push, in-app)
- Notification frequency settings
- Delivery preferences
- **Notification Preferences** - User-configurable notification settings
5. **Document Configuration**
- Allowed file types and extensions
- File size limits (per file and total)
- Upload validation rules
- Document policy enforcement
6. **Dashboard Configuration**
- Customize dashboard layout per role
- Widget visibility settings
- Dashboard widget ordering
- Role-specific dashboard views
7. **AI Configuration**
- AI provider settings (OpenAI, Anthropic, etc.)
- AI parameters and model selection
- AI feature toggles
- API key management
8. **Sharing Configuration**
- Sharing policies and permissions
- External sharing settings
- Access control rules
#### Holiday Management
- Configure business holidays for accurate SLA calculations
- Holiday calendar integration
- Regional holiday support
### 📈 Approver Performance Dashboard
Comprehensive analytics dashboard (`/approver-performance`) for tracking and analyzing approver performance:
#### Key Metrics
- **Approval Statistics**: Total approvals, approval rate, average approval time
- **TAT Compliance**: Percentage of approvals within TAT
- **Request History**: Complete list of requests handled by approver
- **Performance Trends**: Time-based performance analysis
- **SLA Metrics**: TAT breach analysis and compliance tracking
#### Features
- **Advanced Filtering**: Filter by date range, status, priority, SLA compliance
- **Search Functionality**: Search requests by title, ID, or description
- **Export Capabilities**: Export performance data in CSV/Excel formats
- **Visual Analytics**: Charts and graphs for performance visualization
- **Comparison Tools**: Compare performance across different time periods
- **Detailed Request List**: View all requests with approval details
### 💬 Real-Time Live Chat (Work Notes)
Powered by Socket.IO for instant, bidirectional communication within request context:
#### Core Features
- **Real-Time Messaging**: Instant message delivery with Socket.IO
- **Presence Indicators**: See who's online/offline in real-time
- **@Mention System**: Mention participants using @username syntax
- **File Attachments**: Upload and share documents directly in chat
- **Message History**: Persistent chat history per request
- **Rich Text Support**: Format messages with mentions and links
#### Technical Implementation
- **Socket.IO Client**: Real-time WebSocket communication
- **Room-Based Architecture**: Each request has isolated chat room
- **Auto-Reconnection**: Automatic reconnection on network issues
- **Presence Management**: Real-time user presence tracking
- **Message Persistence**: Messages stored in backend database
#### Usage
- Access via Request Detail page > Work Notes tab
- Full-screen chat interface available
- Real-time updates for all participants
- Notification on new messages
### 🔔 Web Push Notifications
Browser push notifications using VAPID (Voluntary Application Server Identification) protocol:
#### Features
- **Service Worker Integration**: Background notification delivery
- **VAPID Protocol**: Secure, standards-based push notifications
- **Permission Management**: User-friendly permission requests
- **Notification Preferences**: User-configurable notification settings
- **Cross-Platform Support**: Works on desktop and mobile browsers
- **Offline Queue**: Notifications queued when browser is offline
#### Configuration
1. Generate VAPID keys (public/private pair)
2. Set `VITE_PUBLIC_VAPID_KEY` in environment variables
3. Backend must support VAPID push notification delivery
4. Service worker automatically registers on app load
#### Notification Types
- **SLA Alerts**: TAT breach and approaching deadline notifications
- **Approval Requests**: New requests assigned to approver
- **Status Updates**: Request status change notifications
- **Work Note Mentions**: Notifications when mentioned in chat
- **System Alerts**: Critical system notifications
#### Browser Support
- Chrome/Edge: Full support
- Firefox: Full support
- Safari: Limited support (macOS/iOS)
- Requires HTTPS in production
## 🔧 Troubleshooting
### Common Issues
#### WebSocket Connection Issues
If real-time features (chat, presence) are not working:
1. Verify backend Socket.IO server is running
2. Check `VITE_BASE_URL` in `.env.local` (should not include `/api/v1`)
3. Ensure CORS allows WebSocket connections
4. Check browser console for Socket.IO connection errors
5. Verify Socket.IO path is `/socket.io` (default)
\`\`\`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:
\`\`\`bash
# Option 1: Kill the process using the port
# Windows
netstat -ano | findstr :5173
taskkill /PID <PID> /F
# Unix/Linux/Mac
lsof -ti:5173 | xargs kill -9
# Option 2: Use a different port
npm run dev -- --port 3000
\`\`\`
#### Environment Variables Not Loading
1. Ensure variables are prefixed with `VITE_`
2. Restart the dev server after changing `.env` files
3. Check that `.env.local` exists in the project root
4. Verify no typos in variable names
#### Backend Connection Issues
1. Verify backend is running on the configured port
2. Check `VITE_API_BASE_URL` in `.env.local` matches backend URL
3. Ensure CORS is configured in backend to allow frontend origin
4. Check browser console for detailed error messages
#### Build Errors
\`\`\`bash
# Clear cache and rebuild
rm -rf node_modules/.vite
npm run build
# Check for TypeScript errors
npm run type-check
\`\`\`
### Getting Help
- Check browser console for errors
- Verify all environment variables are set correctly
- Ensure Node.js and npm versions meet requirements
- Review backend logs for API-related issues
- Check Network tab for WebSocket connection status
- Verify service worker registration in DevTools > Application
## 🔐 Role-Based Access Control
The application supports three user roles with different access levels:
### USER Role
- Create and manage own requests
- View assigned requests
- Approve/reject requests assigned to them
- Add work notes and comments
- Upload documents
### MANAGEMENT Role
- All USER permissions
- View all requests across the organization
- Access to detailed reports and analytics
- Approver Performance dashboard access
- Export capabilities
### ADMIN Role
- All MANAGEMENT permissions
- Access to Admin Control Panel (`/admin`)
- User management and role assignment
- System configuration (TAT, Notifications, Documents, etc.)
- KPI configuration and dashboard customization
- Holiday calendar management
- Full system administration capabilities
## 🧪 Testing (Future Enhancement)
Add testing framework:
\`\`\`bash
npm install -D vitest @testing-library/react @testing-library/jest-dom
\`\`\`
## 🤝 Contributing
1. Follow the existing code style
2. Run `npm run lint:fix` before committing
3. Run `npm run type-check` to ensure type safety
4. Write meaningful commit messages
## 📝 License
Private - Royal Enfield Internal Use Only
## 👥 Team
Built by the Royal Enfield Development Team
---
**For support or questions, contact the development team.**
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

View File

@ -1,105 +0,0 @@
# Form 16 Submission Status Screen, Alerts, Validations & Versions
This document describes the **submission status screen** shown to the dealer after submitting Form 16A, and related features: alerts, validations, and submission versions.
---
## 1. Submission status screen (post-submit)
**URL (after submit):** `http://localhost:3001/form16/submit/result` (or same path on your host).
**When it appears:** After the dealer uploads Form 16A, OCR extraction runs (Gemini/regex), they correct TAN/deductor if needed, and click **Submit Form 16A**. The app then navigates to this result screen with a status.
### 1.1 Statuses
| Status | UI label | When it happens |
|-------------|------------------------------------|-----------------|
| **success** | Matched & Credit Note Generated | Form 16A matched 26AS and a credit note was generated. *(Currently backend does not return this at submit time; matching is async. Can be shown when opening the request detail if a credit note exists.)* |
| **mismatch**| Value Mismatch | Form 16A details did not match 26AS data. *(Requires backend to perform sync validation and return this.)* |
| **duplicate** | Duplicate Submission | Same Form 16A (or same dealer + FY + quarter) already submitted. API returns 400 with duplicate message. |
| **error** | Request Received | Submission accepted; request created and is being processed. Shown for every successful `POST /form16/submissions` until sync matching is implemented. |
### 1.2 Process flow (timeline on the screen)
1. **Form 16A Uploaded** completed once file is uploaded and OCR ran.
2. **Validation** completed (or failed for duplicate).
3. **26AS Matching** pending until matching runs; completed on success; failed on mismatch/duplicate.
4. **Credit Note** pending until a credit note is generated; completed on success.
### 1.3 Actions on result screen
- **Success:** “Back to My Requests”; auto-redirect after 5 seconds.
- **Request Received (error):** “View Request”, “Try Again”, “Back to My Requests”.
- **Duplicate:** “Back to New Submission”, “Back to My Requests”.
- **Mismatch:** “Resubmit Form 16A”, “Back to My Requests”.
State is passed via React Router `location.state` and persisted in `sessionStorage` under `form16_submission_result` so a refresh still shows the result.
---
## 2. Alerts (admin-configured)
Form 16 alerts are driven by **admin config** (e.g. `Form16AdminConfig`). Relevant keys:
| Config key | Purpose |
|--------------------------------|---------|
| `alertSubmitForm16Enabled` | Whether to send “submit Form 16” alerts to dealers. |
| `alertSubmitForm16FrequencyDays` | Frequency (days) for the alert. |
| `alertSubmitForm16FrequencyHours`| Frequency (hours) for the alert. |
| `alertSubmitForm16Template` | Message template; placeholders e.g. `[Name]`, `[DueDate]`. |
Notifications (success/failure) use:
- `notificationForm16SuccessCreditNote` e.g. “Form 16 submitted successfully. Credit note: [CreditNoteRef].”
- `notificationForm16Unsuccessful` e.g. “Form 16 submission was unsuccessful. Issue: [Issue].”
These are stored in the workflow admin config (e.g. `form16` or `FORM16` config blob) and used by backend/cron for sending alerts and notifications.
---
## 3. Validations
- **Client-side (submit page):**
- Financial year and quarter required.
- File must be PDF.
- TAN and deductor name required (from OCR or manual).
- Toast errors if validation fails.
- **Backend (`POST /form16/submissions`):**
- Required: `financialYear`, `quarter`, `form16aNumber`, `tanNumber`, `deductorName`.
- `tdsAmount` and `totalAmount` must be valid numbers ≥ 0.
- Duplicate check: if the backend detects an existing submission (e.g. same certificate or same dealer + FY + quarter), it returns **400** with a message containing “duplicate” or “already exist”; the frontend then shows the **duplicate** status on the result screen.
- No synchronous 26AS matching or credit note creation at submit time in the current implementation; validation status is updated asynchronously (e.g. `form16a_submissions.validation_status`).
---
## 4. Submission versions
- **Model:** `form16a_submissions.version` (number; default 1).
- **Submit payload:** Optional `version` in the create-submission payload (e.g. for “resubmit” or revised certificate).
- **UI note (Form16Submit):** “If you submit multiple versions for the same quarter, only the latest approved version will be processed.”
- Version is stored per submission row; business logic (e.g. “latest approved”) is applied when listing or matching (backend / RE views).
---
## 5. Where things live in the repo
| Item | Location |
|------|----------|
| Submission result screen | `src/pages/Form16/components/RequestSubmissionSuccess.tsx` |
| Result page (route + state) | `src/pages/Form16/Form16SubmissionResult.tsx` |
| Submit page (navigate to result) | `src/pages/Form16/Form16Submit.tsx` |
| Status chip / timeline | `src/pages/Form16/components/StatusChip.tsx`, `TimelineStep.tsx` |
| Route | `App.tsx`: `/form16/submit/result` |
| Form 16 config (alerts, notifications) | Backend admin config; frontend `Form16AdminConfig` (e.g. under admin settings). |
---
## 6. Local URL
Form 16 (submit and result) is under the same app; typical local URL:
- Submit: `http://localhost:3001/form16/submit`
- Result: `http://localhost:3001/form16/submit/result` (after submit or when state is in `sessionStorage`).
Replace `3001` with your frontend port if different.

12
package-lock.json generated
View File

@ -73,7 +73,6 @@
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.10.0",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-react-refresh": "^0.4.14",
@ -3599,16 +3598,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "integrity": "sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.cjs" "baseline-browser-mapping": "dist/cli.js"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/binary-extensions": { "node_modules/binary-extensions": {

View File

@ -78,7 +78,6 @@
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.10.0",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-react-refresh": "^0.4.14",

View File

@ -25,14 +25,6 @@ import { DetailedReports } from '@/pages/DetailedReports';
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList'; import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate'; import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest'; import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
import { Admin } from '@/pages/Admin/Admin';
import { Form16CreditNotes } from '@/pages/Form16/Form16CreditNotes';
import { Form16CreditNoteDetail } from '@/pages/Form16/Form16CreditNoteDetail';
import { Form16Submit } from '@/pages/Form16/Form16Submit';
import { Form16SubmissionResult } from '@/pages/Form16/Form16SubmissionResult';
import { Form16_26AS } from '@/pages/Form16/Form16_26AS';
import { Form16NonSubmittedDealers } from '@/pages/Form16/Form16NonSubmittedDealers';
import { Form16PendingSubmissions } from '@/pages/Form16/Form16PendingSubmissions';
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -453,12 +445,11 @@ function AppRoutes({ onLogout }: AppProps) {
{/* Admin Routes Group with Shared Layout */} {/* Admin Routes Group with Shared Layout */}
<Route <Route
element={ element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Outlet /> <Outlet />
</PageLayout> </PageLayout>
} }
> >
<Route path="/admin" element={<Admin />} />
<Route path="/admin/create-template" element={<CreateTemplate />} /> <Route path="/admin/create-template" element={<CreateTemplate />} />
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} /> <Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
<Route path="/admin/templates" element={<AdminTemplatesList />} /> <Route path="/admin/templates" element={<AdminTemplatesList />} />
@ -514,74 +505,6 @@ function AppRoutes({ onLogout }: AppProps) {
} }
/> />
{/* Form 16 Credit Notes (dealer) */}
<Route
path="/form16/credit-notes"
element={
<PageLayout currentPage="form16-credit-notes" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Form16CreditNotes />
</PageLayout>
}
/>
<Route
path="/form16/credit-notes/:id"
element={
<PageLayout currentPage="form16-credit-notes" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Form16CreditNoteDetail />
</PageLayout>
}
/>
{/* Form 16 Submit (dealer) */}
<Route
path="/form16/submit"
element={
<PageLayout currentPage="form16-submit" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Form16Submit />
</PageLayout>
}
/>
{/* Form 16 Submission result (after dealer submits) */}
<Route
path="/form16/submit/result"
element={
<PageLayout currentPage="form16-submit" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Form16SubmissionResult />
</PageLayout>
}
/>
{/* Form 16 26AS (RE) */}
<Route
path="/form16/26as"
element={
<PageLayout currentPage="form16-26as" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Form16_26AS />
</PageLayout>
}
/>
{/* Form 16 Pending Submissions (dealer) */}
<Route
path="/form16/pending-submissions"
element={
<PageLayout currentPage="form16-pending-submissions" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Form16PendingSubmissions />
</PageLayout>
}
/>
{/* Form 16 Non-submitted dealers (RE) */}
<Route
path="/form16/non-submitted-dealers"
element={
<PageLayout currentPage="form16-non-submitted-dealers" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Form16NonSubmittedDealers />
</PageLayout>
}
/>
{/* My Requests */} {/* My Requests */}
<Route <Route
path="/my-requests" path="/my-requests"

View File

@ -1,585 +0,0 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { FileText, Database, Bell, Loader2, Plus, Trash2, Save } from 'lucide-react';
import {
getForm16Config,
putForm16Config,
type Form16AdminConfig as Form16ConfigType,
type Form16NotificationItem,
type Form16Notification26AsItem,
} from '@/services/adminApi';
import { toast } from 'sonner';
function isValidEmail(s: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
}
interface ViewerTableProps {
emails: string[];
onAdd: (email: string) => void;
onRemove: (email: string) => void;
placeholder?: string;
}
function ViewerTable({ emails, onAdd, onRemove, placeholder }: ViewerTableProps) {
const [input, setInput] = useState('');
const add = () => {
const e = input.trim().toLowerCase();
if (!e) return;
if (!isValidEmail(e)) {
toast.error('Please enter a valid email address');
return;
}
if (emails.includes(e)) {
toast.error('This email is already in the list');
return;
}
onAdd(e);
setInput('');
};
return (
<div className="space-y-3">
<div className="flex gap-2">
<Input
type="email"
placeholder={placeholder ?? 'e.g., user@royalenfield.com'}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
className="flex-1"
/>
<Button type="button" variant="outline" size="sm" onClick={add} className="shrink-0 gap-1">
<Plus className="w-4 h-4" />
Add
</Button>
</div>
{emails.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead className="w-[80px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{emails.map((email) => (
<TableRow key={email}>
<TableCell className="font-medium">{email}</TableCell>
<TableCell className="text-right">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(email)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center rounded-md border border-dashed">
No viewers added. Add emails above or leave empty to allow all RE users with access.
</p>
)}
</div>
);
}
function defaultNotif(enabled: boolean, template: string): Form16NotificationItem {
return { enabled, template };
}
const default26AsNotif = (): Form16Notification26AsItem => ({
enabled: true,
templateRe: '26AS data has been added. Please review and use for matching dealer Form 16 submissions.',
templateDealers: 'New 26AS data has been uploaded. You can now submit your Form 16 for the relevant quarter if you havent already.',
});
export function Form16AdminConfig() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [submissionViewerEmails, setSubmissionViewerEmails] = useState<string[]>([]);
const [twentySixAsViewerEmails, setTwentySixAsViewerEmails] = useState<string[]>([]);
const [reminderEnabled, setReminderEnabled] = useState(true);
const [reminderDays, setReminderDays] = useState(7);
const [notification26AsDataAdded, setNotification26AsDataAdded] = useState<Form16Notification26AsItem>(default26AsNotif());
const [notificationForm16SuccessCreditNote, setNotificationForm16SuccessCreditNote] = useState<Form16NotificationItem>(defaultNotif(true, 'Form 16 submitted successfully. Credit note: [CreditNoteRef].'));
const [notificationForm16Unsuccessful, setNotificationForm16Unsuccessful] = useState<Form16NotificationItem>(defaultNotif(true, 'Form 16 submission was unsuccessful. Issue: [Issue]. Please review.'));
const [alertSubmitForm16Enabled, setAlertSubmitForm16Enabled] = useState(true);
const [alertSubmitForm16FrequencyDays, setAlertSubmitForm16FrequencyDays] = useState(0);
const [alertSubmitForm16FrequencyHours, setAlertSubmitForm16FrequencyHours] = useState(24);
const [alertSubmitForm16RunAtTime, setAlertSubmitForm16RunAtTime] = useState('09:00');
const [alertSubmitForm16Template, setAlertSubmitForm16Template] = useState('Please submit your Form 16 at your earliest. [Name], due date: [DueDate].');
const [reminderNotificationEnabled, setReminderNotificationEnabled] = useState(true);
const [reminderFrequencyDays, setReminderFrequencyDays] = useState(0);
const [reminderFrequencyHours, setReminderFrequencyHours] = useState(12);
const [reminderRunAtTime, setReminderRunAtTime] = useState('10:00');
const [reminderNotificationTemplate, setReminderNotificationTemplate] = useState('Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.');
useEffect(() => {
let mounted = true;
getForm16Config()
.then((config: Form16ConfigType) => {
if (!mounted) return;
setSubmissionViewerEmails(config.submissionViewerEmails ?? []);
setTwentySixAsViewerEmails(config.twentySixAsViewerEmails ?? []);
setReminderEnabled(config.reminderEnabled ?? true);
setReminderDays(typeof config.reminderDays === 'number' ? config.reminderDays : 7);
if (config.notification26AsDataAdded) {
const n = config.notification26AsDataAdded as Form16Notification26AsItem & { template?: string };
setNotification26AsDataAdded({
enabled: n.enabled ?? true,
templateRe: n.templateRe ?? n.template ?? default26AsNotif().templateRe,
templateDealers: n.templateDealers ?? default26AsNotif().templateDealers,
});
}
if (config.notificationForm16SuccessCreditNote) setNotificationForm16SuccessCreditNote(config.notificationForm16SuccessCreditNote);
if (config.notificationForm16Unsuccessful) setNotificationForm16Unsuccessful(config.notificationForm16Unsuccessful);
setAlertSubmitForm16Enabled(config.alertSubmitForm16Enabled ?? true);
setAlertSubmitForm16FrequencyDays(config.alertSubmitForm16FrequencyDays ?? 0);
setAlertSubmitForm16FrequencyHours(config.alertSubmitForm16FrequencyHours ?? 24);
setAlertSubmitForm16RunAtTime(config.alertSubmitForm16RunAtTime !== undefined && config.alertSubmitForm16RunAtTime !== null ? config.alertSubmitForm16RunAtTime : '09:00');
setAlertSubmitForm16Template(config.alertSubmitForm16Template ?? 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].');
setReminderNotificationEnabled(config.reminderNotificationEnabled ?? true);
setReminderFrequencyDays(config.reminderFrequencyDays ?? 0);
setReminderFrequencyHours(config.reminderFrequencyHours ?? 12);
setReminderRunAtTime(config.reminderRunAtTime !== undefined && config.reminderRunAtTime !== null ? config.reminderRunAtTime : '10:00');
setReminderNotificationTemplate(config.reminderNotificationTemplate ?? 'Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.');
})
.catch(() => {
if (mounted) toast.error('Failed to load Form 16 configuration');
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, []);
const handleSave = async () => {
setSaving(true);
try {
await putForm16Config({
submissionViewerEmails,
twentySixAsViewerEmails,
reminderEnabled,
reminderDays: Math.max(1, Math.min(365, reminderDays)) || 7,
notification26AsDataAdded,
notificationForm16SuccessCreditNote,
notificationForm16Unsuccessful,
alertSubmitForm16Enabled,
alertSubmitForm16FrequencyDays: Math.max(0, Math.min(365, alertSubmitForm16FrequencyDays)),
alertSubmitForm16FrequencyHours: Math.max(0, Math.min(168, alertSubmitForm16FrequencyHours)),
alertSubmitForm16RunAtTime: alertSubmitForm16RunAtTime ?? '',
alertSubmitForm16Template,
reminderNotificationEnabled,
reminderFrequencyDays: Math.max(0, Math.min(365, reminderFrequencyDays)),
reminderFrequencyHours: Math.max(0, Math.min(168, reminderFrequencyHours)),
reminderRunAtTime: reminderRunAtTime ?? '',
reminderNotificationTemplate,
});
toast.success('Form 16 configuration saved');
} catch {
toast.error('Failed to save Form 16 configuration');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-re-green" />
</div>
);
}
return (
<div className="space-y-6">
{/* Page header */}
<div>
<h2 className="text-2xl font-bold tracking-tight">Form 16 Administration</h2>
<p className="text-muted-foreground mt-1">
Configure Form 16 access, who can view submission data and 26AS, and notification settings.
</p>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">Submission data viewers (RE)</p>
<p className="text-2xl font-semibold">{submissionViewerEmails.length}</p>
<p className="text-xs text-muted-foreground mt-0.5">Who can see Form 16 submissions</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">26AS viewers (RE)</p>
<p className="text-2xl font-semibold">{twentySixAsViewerEmails.length}</p>
<p className="text-xs text-muted-foreground mt-0.5">Who can see 26AS page</p>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<p className="text-sm text-muted-foreground">Reminders to dealers</p>
<p className="text-2xl font-semibold text-green-600">{reminderEnabled ? 'On' : 'Off'}</p>
<p className="text-xs text-muted-foreground mt-0.5">Pending Form 16 reminder schedule</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg">
<p className="text-sm text-muted-foreground">Email / in-app notifications</p>
<p className="text-2xl font-semibold text-purple-600">
{[
notification26AsDataAdded?.enabled,
notificationForm16SuccessCreditNote?.enabled,
notificationForm16Unsuccessful?.enabled,
alertSubmitForm16Enabled,
reminderNotificationEnabled,
].filter(Boolean).length}{' '}
/ 5 enabled
</p>
<p className="text-xs text-muted-foreground mt-0.5">To dealers and RE as per rules below</p>
</div>
</div>
{/* Submission data viewers */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Submission data who can see
</CardTitle>
<CardDescription>
Users with these email addresses can see Form 16 submission data (and the Form 16 menu in the sidebar). Use the <strong>exact login email</strong> of each user (the same email they use to sign in). Leave the list empty to allow all RE users with Form 16 access.
</CardDescription>
</CardHeader>
<CardContent>
<ViewerTable
emails={submissionViewerEmails}
onAdd={(email) => setSubmissionViewerEmails((prev) => [...prev, email].sort())}
onRemove={(email) => setSubmissionViewerEmails((prev) => prev.filter((e) => e !== email))}
placeholder="e.g., user@royalenfield.com"
/>
</CardContent>
</Card>
{/* 26AS viewers */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
26AS page and button who can see
</CardTitle>
<CardDescription>
Users with these email addresses can see the 26AS page and 26AS menu item. Use the <strong>exact login email</strong> of each user. Leave empty to allow all RE users.
</CardDescription>
</CardHeader>
<CardContent>
<ViewerTable
emails={twentySixAsViewerEmails}
onAdd={(email) => setTwentySixAsViewerEmails((prev) => [...prev, email].sort())}
onRemove={(email) => setTwentySixAsViewerEmails((prev) => prev.filter((e) => e !== email))}
placeholder="e.g., user@royalenfield.com"
/>
</CardContent>
</Card>
{/* Notifications and reminders (simple toggles) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
Reminder schedule (for dealers)
</CardTitle>
<CardDescription>
When reminders are enabled, dealers with pending Form 16 for a quarter are reminded at this interval. Set how often (in days) the system may send them a reminder to submit Form 16.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="reminder-enabled">Enable reminders to dealers</Label>
<Switch
id="reminder-enabled"
checked={reminderEnabled}
onCheckedChange={setReminderEnabled}
/>
</div>
<div className="space-y-2 max-w-xs">
<Label htmlFor="reminder-days">Remind dealers every (days)</Label>
<Input
id="reminder-days"
type="number"
min={1}
max={365}
value={reminderDays}
onChange={(e) => setReminderDays(parseInt(e.target.value, 10) || 7)}
/>
</div>
</CardContent>
</Card>
{/* Notification Configuration Form 16 events */}
<Card>
<CardHeader>
<CardTitle>Email and in-app notifications</CardTitle>
<CardDescription>
Configure who receives each notification, what triggers it, and when it is sent. Templates support placeholders such as [Name], [Request ID], [DueDate].
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<h4 className="font-medium">Form 16 notifications recipient and trigger</h4>
{/* 26AS data added separate message for RE users and for dealers */}
<div className="flex items-start justify-between gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex-1 min-w-0 space-y-3">
<p className="font-medium">26AS data added</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Sent to:</span> RE users who can view 26AS, and separately to all dealers. <span className="font-medium text-foreground">When:</span> As soon as new 26AS data is uploaded.
</p>
<div className="space-y-2">
<div>
<Label className="text-xs text-muted-foreground">Message to RE users</Label>
<Textarea
rows={2}
value={notification26AsDataAdded.templateRe ?? ''}
onChange={(e) => setNotification26AsDataAdded((prev) => ({ ...prev, templateRe: e.target.value }))}
className="resize-none text-sm mt-1"
placeholder="e.g. 26AS data has been added. Please review..."
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Message to dealers</Label>
<Textarea
rows={2}
value={notification26AsDataAdded.templateDealers ?? ''}
onChange={(e) => setNotification26AsDataAdded((prev) => ({ ...prev, templateDealers: e.target.value }))}
className="resize-none text-sm mt-1"
placeholder="e.g. New 26AS data has been uploaded. You can submit Form 16..."
/>
</div>
<p className="text-xs text-muted-foreground">Placeholders: [Name], [Request ID]</p>
</div>
</div>
<Switch
checked={notification26AsDataAdded.enabled}
onCheckedChange={(enabled) => setNotification26AsDataAdded((prev) => ({ ...prev, enabled }))}
/>
</div>
{/* Successful Form 16 with credit note */}
<div className="flex items-start justify-between gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="font-medium">Form 16 success credit note issued</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Sent to:</span> The dealer who submitted the Form 16. <span className="font-medium text-foreground">When:</span> Immediately after their submission is matched with 26AS and a credit note is generated.
</p>
<div className="mt-2">
<Textarea
rows={2}
value={notificationForm16SuccessCreditNote.template ?? ''}
onChange={(e) => setNotificationForm16SuccessCreditNote((prev) => ({ ...prev, template: e.target.value }))}
className="resize-none text-sm"
placeholder="Message template..."
/>
<p className="text-xs text-muted-foreground mt-1">Placeholders: [Name], [CreditNoteRef], [Request ID]</p>
</div>
</div>
<Switch
checked={notificationForm16SuccessCreditNote.enabled}
onCheckedChange={(enabled) => setNotificationForm16SuccessCreditNote((prev) => ({ ...prev, enabled }))}
/>
</div>
{/* Unsuccessful Form 16 */}
<div className="flex items-start justify-between gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="font-medium">Form 16 unsuccessful (mismatch or issue)</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Sent to:</span> The dealer who submitted. <span className="font-medium text-foreground">When:</span> When their submission fails (e.g. value mismatch with 26AS, duplicate, or validation error) so they can correct and resubmit.
</p>
<div className="mt-2">
<Textarea
rows={2}
value={notificationForm16Unsuccessful.template ?? ''}
onChange={(e) => setNotificationForm16Unsuccessful((prev) => ({ ...prev, template: e.target.value }))}
className="resize-none text-sm"
placeholder="Message template..."
/>
<p className="text-xs text-muted-foreground mt-1">Placeholders: [Name], [Issue], [Request ID]</p>
</div>
</div>
<Switch
checked={notificationForm16Unsuccessful.enabled}
onCheckedChange={(enabled) => setNotificationForm16Unsuccessful((prev) => ({ ...prev, enabled }))}
/>
</div>
{/* Alert to submit Form 16 (auto, configurable) */}
<div className="flex items-start justify-between gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="font-medium">Alert submit Form 16 (to dealers who havent submitted)</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Sent to:</span> Dealers who have not yet submitted Form 16 for the current FY. <span className="font-medium text-foreground">When:</span> Daily at the time below (server timezone). All settings are API-driven from this config.
</p>
<div className="mt-2 space-y-2">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="alert-run-at" className="text-sm whitespace-nowrap">Run daily at (optional):</Label>
<Input
id="alert-run-at"
type="time"
value={alertSubmitForm16RunAtTime}
onChange={(e) => setAlertSubmitForm16RunAtTime(e.target.value)}
className="w-28"
/>
{alertSubmitForm16RunAtTime ? (
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setAlertSubmitForm16RunAtTime('')}>
Clear
</Button>
) : null}
</div>
<span className="text-xs text-muted-foreground">24h, server TZ. Leave empty to disable daily run.</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="alert-freq-days" className="text-sm whitespace-nowrap">Frequency (days):</Label>
<Input
id="alert-freq-days"
type="number"
min={0}
max={365}
value={alertSubmitForm16FrequencyDays}
onChange={(e) => setAlertSubmitForm16FrequencyDays(Math.max(0, parseInt(e.target.value, 10) || 0))}
className="w-20"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="alert-freq-hours" className="text-sm whitespace-nowrap">Hours:</Label>
<Input
id="alert-freq-hours"
type="number"
min={0}
max={168}
value={alertSubmitForm16FrequencyHours}
onChange={(e) => setAlertSubmitForm16FrequencyHours(Math.max(0, parseInt(e.target.value, 10) || 0))}
className="w-20"
/>
</div>
</div>
<div>
<Textarea
rows={2}
value={alertSubmitForm16Template}
onChange={(e) => setAlertSubmitForm16Template(e.target.value)}
className="resize-none text-sm mt-1"
placeholder="Message template for alert to dealers..."
/>
<p className="text-xs text-muted-foreground mt-1">Placeholders: [Name], [DueDate], [Request ID]</p>
</div>
</div>
</div>
<Switch
checked={alertSubmitForm16Enabled}
onCheckedChange={setAlertSubmitForm16Enabled}
/>
</div>
{/* Reminder notification (pending Form 16) */}
<div className="flex items-start justify-between gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="font-medium">Reminder pending Form 16 (to dealers)</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Sent to:</span> Dealers who have at least one open Form 16 submission without a credit note. <span className="font-medium text-foreground">When:</span> Daily at the time below (server timezone). All settings are API-driven from this config.
</p>
<div className="mt-2 space-y-2">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="reminder-run-at" className="text-sm whitespace-nowrap">Run daily at (optional):</Label>
<Input
id="reminder-run-at"
type="time"
value={reminderRunAtTime}
onChange={(e) => setReminderRunAtTime(e.target.value)}
className="w-28"
/>
{reminderRunAtTime ? (
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setReminderRunAtTime('')}>
Clear
</Button>
) : null}
</div>
<span className="text-xs text-muted-foreground">24h, server TZ. Leave empty to disable daily run.</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="reminder-freq-days" className="text-sm whitespace-nowrap">Frequency (days):</Label>
<Input
id="reminder-freq-days"
type="number"
min={0}
max={365}
value={reminderFrequencyDays}
onChange={(e) => setReminderFrequencyDays(Math.max(0, parseInt(e.target.value, 10) || 0))}
className="w-20"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="reminder-freq-hours" className="text-sm whitespace-nowrap">Hours:</Label>
<Input
id="reminder-freq-hours"
type="number"
min={0}
max={168}
value={reminderFrequencyHours}
onChange={(e) => setReminderFrequencyHours(Math.max(0, parseInt(e.target.value, 10) || 0))}
className="w-20"
/>
</div>
</div>
<div>
<Textarea
rows={2}
value={reminderNotificationTemplate}
onChange={(e) => setReminderNotificationTemplate(e.target.value)}
className="resize-none text-sm mt-1"
placeholder="Message template for reminder to dealers..."
/>
<p className="text-xs text-muted-foreground mt-1">Placeholders: [Name], [Request ID], [Status], [TAT]</p>
</div>
</div>
</div>
<Switch
checked={reminderNotificationEnabled}
onCheckedChange={setReminderNotificationEnabled}
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving} className="bg-re-green hover:bg-re-green/90 gap-2">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Form 16 configuration
</Button>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Bell, Settings, User, Plus, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List, Share2, ChevronDown, ChevronRight, Receipt, Shield } from 'lucide-react'; import { Bell, Settings, User, Plus, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List, Share2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@ -17,7 +17,6 @@ import {
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { ReLogo } from '@/assets'; import { ReLogo } from '@/assets';
import notificationApi, { Notification } from '@/services/notificationApi'; import notificationApi, { Notification } from '@/services/notificationApi';
import { getForm16Permissions, type Form16Permissions } from '@/services/form16Api';
import { getSocket, joinUserRoom } from '@/utils/socket'; import { getSocket, joinUserRoom } from '@/utils/socket';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { TokenManager } from '@/utils/tokenManager'; import { TokenManager } from '@/utils/tokenManager';
@ -32,12 +31,10 @@ interface PageLayoutProps {
export function PageLayout({ children, currentPage = 'dashboard', onNavigate, onNewRequest, onLogout }: PageLayoutProps) { export function PageLayout({ children, currentPage = 'dashboard', onNavigate, onNewRequest, onLogout }: PageLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [form16Expanded, setForm16Expanded] = useState(() => currentPage?.startsWith('form16') ?? false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false); const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const [form16Permissions, setForm16Permissions] = useState<Form16Permissions | null>(null);
const { user } = useAuth(); const { user } = useAuth();
// Check if user is a Dealer // Check if user is a Dealer
@ -51,9 +48,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
} }
}, []); }, []);
// Check if user is Admin (role from backend)
const isAdmin = user?.role === 'ADMIN';
// Get user initials for avatar // Get user initials for avatar
const getUserInitials = () => { const getUserInitials = () => {
try { try {
@ -93,41 +87,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 } { id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
); );
// Admin-only: link to Admin panel (configuration, user management, etc.)
// Disabled: admin configurations are now under Settings (User icon → Settings → Templates, etc.)
const showAdminSidebar = false;
if (isAdmin && showAdminSidebar) {
items.push({ id: 'admin', label: 'Admin', icon: Shield });
}
return items; return items;
}, [isDealer, isAdmin]); }, [isDealer]);
// Form 16 permissions (API-driven from admin config)
useEffect(() => {
if (!user?.userId) {
setForm16Permissions(null);
return;
}
let mounted = true;
getForm16Permissions()
.then((p) => { if (mounted) setForm16Permissions(p); })
.catch((err) => {
if (mounted) {
setForm16Permissions({ canViewForm16Submission: false, canView26AS: false });
console.warn('[PageLayout] Form 16 permissions could not be loaded Form 16 menu will be hidden.', err);
}
});
return () => { mounted = false; };
}, [user?.userId]);
const showForm16Section = form16Permissions && (form16Permissions.canViewForm16Submission || form16Permissions.canView26AS);
const canViewForm16Submission = !!form16Permissions?.canViewForm16Submission;
const canView26AS = !!form16Permissions?.canView26AS;
// Keep Form 16 expanded when on a Form 16 page (dealer or RE)
const isForm16Page = currentPage === 'form16-credit-notes' || currentPage === 'form16-submit' || currentPage === 'form16-pending-submissions' || currentPage === 'form16-26as' || currentPage === 'form16-non-submitted-dealers';
const form16ExpandedOrActive = form16Expanded || isForm16Page;
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen); setSidebarOpen(!sidebarOpen);
@ -292,8 +253,15 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<button <button
key={item.id} key={item.id}
onClick={() => { onClick={() => {
onNavigate?.(item.id); if (item.id === 'admin/templates') {
if (window.innerWidth < 768) setSidebarOpen(false); onNavigate?.('admin/templates');
} else {
onNavigate?.(item.id);
}
// Close sidebar on mobile after navigation
if (window.innerWidth < 768) {
setSidebarOpen(false);
}
}} }}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id
? 'bg-re-green text-white font-medium' ? 'bg-re-green text-white font-medium'
@ -304,138 +272,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
</button> </button>
))} ))}
{/* Form 16 collapsible section visibility from API-driven permissions (admin config) */}
{showForm16Section && (
<div className="pt-2 border-t border-gray-800">
<button
type="button"
onClick={() => setForm16Expanded(!form16ExpandedOrActive)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isForm16Page ? 'bg-re-green text-white font-medium' : 'text-gray-300 hover:bg-gray-900 hover:text-white'
}`}
>
<Receipt className="w-4 h-4 shrink-0" />
<span className="truncate flex-1 text-left">Form 16</span>
{form16ExpandedOrActive ? (
<ChevronDown className="w-4 h-4 shrink-0" />
) : (
<ChevronRight className="w-4 h-4 shrink-0" />
)}
</button>
{form16ExpandedOrActive && (
<div className="mt-1 ml-4 pl-2 border-l border-gray-700 space-y-0.5">
{isDealer ? (
<>
{canViewForm16Submission && (
<>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/submit');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-submit'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<FileText className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Submit Form 16</span>
</button>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/pending-submissions');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-pending-submissions'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<FileText className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Pending Submissions</span>
</button>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/credit-notes');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-credit-notes'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<Receipt className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Credit Notes</span>
</button>
</>
)}
</>
) : (
<>
{canView26AS && (
<button
type="button"
onClick={() => {
onNavigate?.('/form16/26as');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-26as'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<FileText className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">26AS Management</span>
</button>
)}
{canViewForm16Submission && (
<>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/non-submitted-dealers');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-non-submitted-dealers'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<Receipt className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Non-submitted Dealers</span>
</button>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/credit-notes');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-credit-notes'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<Receipt className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Credit Notes</span>
</button>
</>
)}
</>
)}
</div>
)}
</div>
)}
</div> </div>
{/* Quick Action in Sidebar - Right below menu items */} {/* Quick Action in Sidebar - Right below menu items */}

View File

@ -8,7 +8,7 @@
import { createContext, useContext, useEffect, useState, ReactNode, useRef } from 'react'; import { createContext, useContext, useEffect, useState, ReactNode, useRef } from 'react';
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react'; import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
import { TokenManager, isTokenExpired } from '../utils/tokenManager'; import { TokenManager, isTokenExpired } from '../utils/tokenManager';
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi, passwordLogin } from '../services/authApi'; import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
import { tanflowLogout } from '../services/tanflowAuth'; import { tanflowLogout } from '../services/tanflowAuth';
interface User { interface User {
@ -33,8 +33,6 @@ interface AuthContextType {
user: User | null; user: User | null;
error: Error | null; error: Error | null;
login: () => Promise<void>; login: () => Promise<void>;
/** Local dealer login (username TESTREFLOW when ENABLE_LOCAL_DEALER_LOGIN is set) */
loginWithPassword?: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
getAccessTokenSilently: () => Promise<string | null>; getAccessTokenSilently: () => Promise<string | null>;
refreshTokenSilently: () => Promise<void>; refreshTokenSilently: () => Promise<void>;
@ -102,31 +100,31 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} }
// PRIORITY 2: Check if URL has logout parameter (from redirect) // PRIORITY 2: Check if URL has logout parameter (from redirect)
// Do NOT treat as logout when we have an OAuth code - we're in the middle of login callback
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const hasOAuthCode = urlParams.has('code'); if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
const hasLogoutParam = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
if (hasLogoutParam && !hasOAuthCode) {
console.log('🚪 Logout parameter detected in URL, clearing all tokens'); console.log('🚪 Logout parameter detected in URL, clearing all tokens');
TokenManager.clearAll(); TokenManager.clearAll();
// Clear auth provider flag and logout-related flags
sessionStorage.removeItem('auth_provider'); sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state'); sessionStorage.removeItem('tanflow_auth_state');
sessionStorage.removeItem('__logout_in_progress__'); sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__'); sessionStorage.removeItem('__force_logout__');
sessionStorage.removeItem('tanflow_logged_out'); sessionStorage.removeItem('tanflow_logged_out');
localStorage.clear(); localStorage.clear();
// Don't clear sessionStorage completely - we might need logout flags
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setIsLoading(false); setIsLoading(false);
// Persist "force re-auth" so that when user clicks "RE Employee Login" we add prompt=login // Clean URL but preserve logout flags if they exist (for prompt=login)
// (URL is cleaned below, so we'd otherwise lose the fact we just logged out) const cleanParams = new URLSearchParams();
try { if (urlParams.has('okta_logged_out')) {
sessionStorage.setItem('__force_reauth_after_logout__', 'true'); cleanParams.set('okta_logged_out', 'true');
} catch (e) {
console.warn('Could not set force reauth flag:', e);
} }
// Use a fully clean URL so next load (e.g. after Okta login) does not see logout params and clear again if (urlParams.has('tanflow_logged_out')) {
window.history.replaceState({}, document.title, '/'); cleanParams.set('tanflow_logged_out', 'true');
}
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
window.history.replaceState({}, document.title, newUrl);
return; return;
} }
@ -223,13 +221,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const handleCallback = async () => { const handleCallback = async () => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
// Detect provider FIRST so we don't strip the URL before TanflowCallback can read code/state
const authProvider = sessionStorage.getItem('auth_provider');
if (authProvider === 'tanflow') {
// Tanflow: do not touch URL or process here - TanflowCallback will read code/state and handle exchange
return;
}
// Check if this is a logout redirect (from Tanflow post-logout redirect) // Check if this is a logout redirect (from Tanflow post-logout redirect)
// If it has logout parameters but no code, it's a logout redirect, not a login callback // If it has logout parameters but no code, it's a logout redirect, not a login callback
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) { if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
@ -246,7 +237,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return; return;
} }
// Mark as processed immediately to prevent duplicate calls (OKTA only) // Mark as processed immediately to prevent duplicate calls
callbackProcessedRef.current = true; callbackProcessedRef.current = true;
const code = urlParams.get('code'); const code = urlParams.get('code');
@ -255,6 +246,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// Clean URL immediately to prevent re-running on re-renders // Clean URL immediately to prevent re-running on re-renders
window.history.replaceState({}, document.title, '/login/callback'); window.history.replaceState({}, document.title, '/login/callback');
// Detect provider from sessionStorage
const authProvider = sessionStorage.getItem('auth_provider');
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
if (authProvider === 'tanflow') {
// Clear the provider flag and let TanflowCallback handle it
// Reset ref so TanflowCallback can process
callbackProcessedRef.current = false;
return;
}
// Handle OKTA callback (default) // Handle OKTA callback (default)
if (errorParam) { if (errorParam) {
setError(new Error(`Authentication error: ${errorParam}`)); setError(new Error(`Authentication error: ${errorParam}`));
@ -463,11 +465,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
sessionStorage.setItem('auth_provider', 'okta'); sessionStorage.setItem('auth_provider', 'okta');
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication // Check if we're coming from a logout - if so, add prompt=login to force re-authentication
// (URL may have been cleaned to '/' on mount, so also check persisted flag)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const hasLogoutInUrl = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out'); const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
const forceReauthFlag = sessionStorage.getItem('__force_reauth_after_logout__') === 'true';
const isAfterLogout = hasLogoutInUrl || forceReauthFlag;
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` + let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
`client_id=${clientId}&` + `client_id=${clientId}&` +
@ -477,10 +476,9 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
`state=${state}`; `state=${state}`;
// Add prompt=login if coming from logout to force re-authentication // Add prompt=login if coming from logout to force re-authentication
// This ensures Okta requires login (password) even if an SSO session still exists // This ensures Okta requires login even if a session still exists
if (isAfterLogout) { if (isAfterLogout) {
authUrl += `&prompt=login`; authUrl += `&prompt=login`;
sessionStorage.removeItem('__force_reauth_after_logout__');
} }
window.location.href = authUrl; window.location.href = authUrl;
@ -490,24 +488,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} }
}; };
const loginWithPassword = async (username: string, password: string) => {
setError(null);
setIsLoading(true);
try {
const result = await passwordLogin(username, password);
setUser(result.user);
setIsAuthenticated(true);
window.history.replaceState({}, document.title, '/');
} catch (err: any) {
setError(err);
setIsAuthenticated(false);
setUser(null);
throw err;
} finally {
setIsLoading(false);
}
};
const logout = async () => { const logout = async () => {
try { try {
//: Get id_token from TokenManager before clearing anything //: Get id_token from TokenManager before clearing anything
@ -676,7 +656,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
user, user,
error, error,
login, login,
loginWithPassword,
logout, logout,
getAccessTokenSilently, getAccessTokenSilently,
refreshTokenSilently, refreshTokenSilently,
@ -745,7 +724,6 @@ function Auth0ContextWrapper({ children }: { children: ReactNode }) {
user: auth0User as User | null, user: auth0User as User | null,
error: auth0Error as Error | null, error: auth0Error as Error | null,
login: loginWithRedirect, login: loginWithRedirect,
loginWithPassword: undefined,
logout: logoutAuth0, logout: logoutAuth0,
getAccessTokenSilently, getAccessTokenSilently,
refreshTokenSilently, refreshTokenSilently,

View File

@ -4,31 +4,11 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, Flame, Target, CheckCircle, XCircle, X } from 'lucide-react'; import { Filter, Search, SortAsc, SortDesc, Flame, Target, CheckCircle, XCircle, X } from 'lucide-react';
const FORM16_QUARTERS = [
{ value: 'Q1', label: 'Q1 (April - June)' },
{ value: 'Q2', label: 'Q2 (July - September)' },
{ value: 'Q3', label: 'Q3 (October - December)' },
{ value: 'Q4', label: 'Q4 (January - March)' },
] as const;
function getFinancialYears(): string[] {
const currentYear = new Date().getFullYear();
const years: string[] = [];
for (let i = 0; i < 4; i++) {
const startYear = currentYear - i;
const endYear = (startYear + 1).toString().slice(-2);
years.push(`${startYear}-${endYear}`);
}
return years;
}
interface ClosedRequestsFiltersProps { interface ClosedRequestsFiltersProps {
searchTerm: string; searchTerm: string;
priorityFilter: string; priorityFilter: string;
statusFilter: string; statusFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
form16FinancialYear?: string;
form16Quarter?: string;
sortBy: 'created' | 'due' | 'priority'; sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
activeFiltersCount: number; activeFiltersCount: number;
@ -36,8 +16,6 @@ interface ClosedRequestsFiltersProps {
onPriorityChange: (value: string) => void; onPriorityChange: (value: string) => void;
onStatusChange: (value: string) => void; onStatusChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void; onTemplateTypeChange: (value: string) => void;
onForm16FinancialYearChange?: (value: string) => void;
onForm16QuarterChange?: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void; onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void; onSortOrderChange: () => void;
onClearFilters: () => void; onClearFilters: () => void;
@ -54,8 +32,6 @@ export function StandardClosedRequestsFilters({
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter, templateTypeFilter,
form16FinancialYear = '',
form16Quarter = '',
sortBy, sortBy,
sortOrder, sortOrder,
activeFiltersCount, activeFiltersCount,
@ -63,14 +39,10 @@ export function StandardClosedRequestsFilters({
onPriorityChange, onPriorityChange,
onStatusChange, onStatusChange,
onTemplateTypeChange, onTemplateTypeChange,
onForm16FinancialYearChange,
onForm16QuarterChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
}: ClosedRequestsFiltersProps) { }: ClosedRequestsFiltersProps) {
const financialYears = getFinancialYears();
const showForm16Filters = templateTypeFilter === 'FORM_16';
return ( return (
<Card className="shadow-lg border-0" data-testid="closed-requests-filters"> <Card className="shadow-lg border-0" data-testid="closed-requests-filters">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6"> <CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
@ -161,41 +133,15 @@ export function StandardClosedRequestsFilters({
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}> <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
<SelectValue placeholder="Request type" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Templates</SelectItem> <SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
<SelectItem value="FORM_16">Form 16</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{showForm16Filters && onForm16FinancialYearChange && (
<Select value={form16FinancialYear || undefined} onValueChange={onForm16FinancialYearChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white">
<SelectValue placeholder="Financial Year" />
</SelectTrigger>
<SelectContent>
{financialYears.map((y) => (
<SelectItem key={y} value={y}>{y}</SelectItem>
))}
</SelectContent>
</Select>
)}
{showForm16Filters && onForm16QuarterChange && (
<Select value={form16Quarter || undefined} onValueChange={onForm16QuarterChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white">
<SelectValue placeholder="Quarter" />
</SelectTrigger>
<SelectContent>
{FORM16_QUARTERS.map((q) => (
<SelectItem key={q.value} value={q.value}>{q.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}> <Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">

View File

@ -4,39 +4,17 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X, Flame, Target } from 'lucide-react'; import { Filter, Search, SortAsc, SortDesc, X, Flame, Target } from 'lucide-react';
const FORM16_QUARTERS = [
{ value: 'Q1', label: 'Q1 (April - June)' },
{ value: 'Q2', label: 'Q2 (July - September)' },
{ value: 'Q3', label: 'Q3 (October - December)' },
{ value: 'Q4', label: 'Q4 (January - March)' },
] as const;
function getFinancialYears(): string[] {
const currentYear = new Date().getFullYear();
const years: string[] = [];
for (let i = 0; i < 4; i++) {
const startYear = currentYear - i;
const endYear = (startYear + 1).toString().slice(-2);
years.push(`${startYear}-${endYear}`);
}
return years;
}
interface RequestsFiltersProps { interface RequestsFiltersProps {
searchTerm: string; searchTerm: string;
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
form16FinancialYear?: string;
form16Quarter?: string;
sortBy: 'created' | 'due' | 'priority' | 'sla'; sortBy: 'created' | 'due' | 'priority' | 'sla';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onStatusFilterChange: (value: string) => void; onStatusFilterChange: (value: string) => void;
onPriorityFilterChange: (value: string) => void; onPriorityFilterChange: (value: string) => void;
onTemplateTypeFilterChange: (value: string) => void; onTemplateTypeFilterChange: (value: string) => void;
onForm16FinancialYearChange?: (value: string) => void;
onForm16QuarterChange?: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void; onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
onSortOrderChange: (value: 'asc' | 'desc') => void; onSortOrderChange: (value: 'asc' | 'desc') => void;
onClearFilters: () => void; onClearFilters: () => void;
@ -54,23 +32,17 @@ export function StandardRequestsFilters({
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter, templateTypeFilter,
form16FinancialYear = '',
form16Quarter = '',
sortBy, sortBy,
sortOrder, sortOrder,
onSearchChange, onSearchChange,
onStatusFilterChange, onStatusFilterChange,
onPriorityFilterChange, onPriorityFilterChange,
onTemplateTypeFilterChange, onTemplateTypeFilterChange,
onForm16FinancialYearChange,
onForm16QuarterChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
activeFiltersCount, activeFiltersCount,
}: RequestsFiltersProps) { }: RequestsFiltersProps) {
const financialYears = getFinancialYears();
const showForm16Filters = templateTypeFilter === 'FORM_16';
return ( return (
<Card className="shadow-lg border-0"> <Card className="shadow-lg border-0">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6"> <CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
@ -150,41 +122,15 @@ export function StandardRequestsFilters({
<Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}> <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="Request type" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Templates</SelectItem> <SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
<SelectItem value="FORM_16">Form 16</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{showForm16Filters && onForm16FinancialYearChange && (
<Select value={form16FinancialYear || undefined} onValueChange={onForm16FinancialYearChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="Financial Year" />
</SelectTrigger>
<SelectContent>
{financialYears.map((y) => (
<SelectItem key={y} value={y}>{y}</SelectItem>
))}
</SelectContent>
</Select>
)}
{showForm16Filters && onForm16QuarterChange && (
<Select value={form16Quarter || undefined} onValueChange={onForm16QuarterChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="Quarter" />
</SelectTrigger>
<SelectContent>
{FORM16_QUARTERS.map((q) => (
<SelectItem key={q.value} value={q.value}>{q.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}> <Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">

View File

@ -20,15 +20,6 @@ import type { DateRange } from '@/services/dashboard.service';
import { CustomDatePicker } from '@/components/ui/date-picker'; import { CustomDatePicker } from '@/components/ui/date-picker';
interface StandardUserAllRequestsFiltersProps { interface StandardUserAllRequestsFiltersProps {
// Dealer-only (optional for standard users ignored)
requestTypeFilter?: string;
onRequestTypeChange?: (value: string) => void;
/** When true, show Form 16 in template dropdown and FY/Quarter filters (for RE users with Form 16 access) */
showForm16Filter?: boolean;
form16FinancialYear?: string;
form16Quarter?: string;
onForm16FinancialYearChange?: (value: string) => void;
onForm16QuarterChange?: (value: string) => void;
// Filters // Filters
searchTerm: string; searchTerm: string;
statusFilter: string; statusFilter: string;
@ -110,11 +101,6 @@ export function StandardUserAllRequestsFilters({
loadingDepartments, loadingDepartments,
initiatorSearch, initiatorSearch,
approverSearch, approverSearch,
showForm16Filter = false,
form16FinancialYear = 'all',
form16Quarter = 'all',
onForm16FinancialYearChange,
onForm16QuarterChange,
onSearchChange, onSearchChange,
onStatusChange, onStatusChange,
onPriorityChange, onPriorityChange,
@ -202,38 +188,9 @@ export function StandardUserAllRequestsFilters({
<SelectItem value="all">All Templates</SelectItem> <SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem> <SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
{showForm16Filter && <SelectItem value="FORM_16">Form 16</SelectItem>}
</SelectContent> </SelectContent>
</Select> </Select>
{showForm16Filter && templateTypeFilter === 'FORM_16' && (
<>
<Select value={form16FinancialYear} onValueChange={onForm16FinancialYearChange ?? (() => {})}>
<SelectTrigger className="h-10" data-testid="form16-financial-year-filter">
<SelectValue placeholder="Financial Year" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Years</SelectItem>
<SelectItem value="2024-25">2024-25</SelectItem>
<SelectItem value="2023-24">2023-24</SelectItem>
<SelectItem value="2022-23">2022-23</SelectItem>
</SelectContent>
</Select>
<Select value={form16Quarter} onValueChange={onForm16QuarterChange ?? (() => {})}>
<SelectTrigger className="h-10" data-testid="form16-quarter-filter">
<SelectValue placeholder="Quarter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Quarters</SelectItem>
<SelectItem value="Q1">Q1</SelectItem>
<SelectItem value="Q2">Q2</SelectItem>
<SelectItem value="Q3">Q3</SelectItem>
<SelectItem value="Q4">Q4</SelectItem>
</SelectContent>
</Select>
</>
)}
<Select <Select
value={departmentFilter} value={departmentFilter}
onValueChange={onDepartmentChange} onValueChange={onDepartmentChange}

View File

@ -1,300 +0,0 @@
/**
* Form 16 Details Overview tab content.
* Only for Form 16 requests. Shows Form 16A certificate details and submission context.
* Does not show generic workflow UI (conclusion, pause, etc.).
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { FormattedDescription } from '@/components/common/FormattedDescription';
import { User, FileText, Mail, Scan } from 'lucide-react';
interface Form16OverviewTabProps {
request: any;
}
function formatInr(val: number | string | null | undefined): string {
if (val == null) return '';
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(Number(val));
}
function displayValue(val: unknown): string {
if (val == null || val === '') return '';
if (typeof val === 'number') return String(val);
return String(val);
}
/** Submission status badge variant by displayStatus mismatch in red */
function getStatusBadgeClass(displayStatus: string | undefined): string {
const s = (displayStatus || '').toLowerCase();
if (s === 'completed') return 'bg-emerald-100 text-emerald-800 border-emerald-200';
if (s === 'duplicate' || s === 'duplicate submission') return 'bg-amber-100 text-amber-800 border-amber-200';
if (s === 'balance mismatch' || s === 'failed') return 'bg-red-100 text-red-800 border-red-200';
if (s === 'resubmission needed' || s === 'partial extracted data') return 'bg-amber-50 text-amber-700 border-amber-200';
return 'bg-gray-100 text-gray-700 border-gray-200';
}
const FRIENDLY_26AS_MISMATCH = 'Failed - data mismatch with 26AS, submit the Form 16 with correct data.';
/** Returns true if notes look like a technical/DB error (do not show to user). */
function isTechnicalError(notes: string): boolean {
const n = (notes || '').toLowerCase();
return /notnull|violation|sequelize|econnrefused|database|error\./i.test(n);
}
/** User-facing message for 26AS mismatch (detail page below status). Never show raw DB errors. */
function getForm16StatusMessage(form16: any): string | null {
const status = (form16?.displayStatus || '').toLowerCase();
const notes = String(form16?.validationNotes || '');
const is26AsMismatch = status === 'balance mismatch' || status === 'failed';
if (is26AsMismatch) return isTechnicalError(notes) ? FRIENDLY_26AS_MISMATCH : (notes.trim() || FRIENDLY_26AS_MISMATCH);
if (status === 'duplicate' || status === 'duplicate submission') return 'Duplicate. A submission for this FY and quarter already exists.';
if (status === 'resubmission needed') return form16?.validationNotes || 'Resubmission needed. Please submit again with correct data.';
return null;
}
export function Form16OverviewTab({ request }: Form16OverviewTabProps) {
const form16 = request?.form16Submission;
const rawOcr = form16?.ocrExtractedData && typeof form16.ocrExtractedData === 'object' ? form16.ocrExtractedData as Record<string, unknown> : null;
// Fallback: use submission fields so OCR section shows even when ocrExtractedData was not stored
const ocrData = rawOcr && Object.keys(rawOcr).length > 0
? rawOcr
: form16
? {
deductorName: form16.deductorName,
deductor_name: form16.deductorName,
tanNumber: form16.tanNumber,
tan_number: form16.tanNumber,
financialYear: form16.financialYear,
financial_year: form16.financialYear,
quarter: form16.quarter,
form16aNumber: form16.form16aNumber,
form16a_number: form16.form16aNumber,
totalAmount: form16.totalAmount,
total_amount: form16.totalAmount,
tdsAmount: form16.tdsAmount,
tds_amount: form16.tdsAmount,
acknowledgementNumber: form16.acknowledgementNumber,
acknowledgement_number: form16.acknowledgementNumber,
dateOfIssue: form16.dateOfIssue,
date_of_issue: form16.dateOfIssue,
deduceeName: form16.deduceeName,
deducee_name: form16.deduceeName,
deduceePan: form16.deduceePan,
deducee_pan: form16.deduceePan,
} as Record<string, unknown>
: null;
return (
<div className="space-y-4 sm:space-y-6" data-testid="form16-overview-tab">
{/* Submission status always show when we have form16 (Completed, Duplicate submission, Balance mismatch, etc.) */}
{form16 && (
<div className="flex flex-col gap-2" data-testid="form16-submission-status">
<div className="flex flex-wrap items-center gap-3">
{form16.displayStatus != null && (
<>
<span className="text-sm font-medium text-gray-700">Submission status:</span>
<Badge variant="outline" className={getStatusBadgeClass(form16.displayStatus)} data-testid="form16-display-status">
{form16.displayStatus}
</Badge>
</>
)}
{form16.displayStatus !== 'Duplicate' && form16.totalAmount != null && (
<span className="text-sm text-gray-600">
<span className="font-medium text-gray-700">Total amount:</span> {formatInr(form16.totalAmount)}
</span>
)}
{form16.displayStatus !== 'Duplicate' && form16.creditNoteNumber && (
<span className="text-sm text-gray-600">
<span className="font-medium text-gray-700">Credit note:</span> {form16.creditNoteNumber}
</span>
)}
</div>
{getForm16StatusMessage(form16) && (
<p className={`text-sm ${(form16.displayStatus || '').toLowerCase() === 'balance mismatch' || (form16.displayStatus || '').toLowerCase() === 'failed' ? 'text-red-700 font-medium' : 'text-gray-600'}`} data-testid="form16-status-message">
{getForm16StatusMessage(form16)}
</p>
)}
</div>
)}
{/* Form 16A Certificate Details */}
<Card data-testid="form16-certificate-details" className="border-emerald-200 bg-emerald-50/30">
<CardHeader className="pb-3 sm:pb-4">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base text-emerald-800">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-emerald-600" />
Form 16A Certificate Details
</CardTitle>
<CardDescription className="text-xs text-gray-600">TDS Certificate as per Income Tax Act, 1961</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{form16 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><p className="text-xs text-gray-500">Certificate Number</p><p className="text-sm font-medium">{form16.form16aNumber || ''}</p></div>
{form16.version != null && form16.version >= 2 && (
<div><p className="text-xs text-gray-500">Version</p><p className="text-sm font-medium">Version {form16.version}</p></div>
)}
<div><p className="text-xs text-gray-500">Financial Year</p><p className="text-sm font-medium">{form16.financialYear || ''}</p></div>
<div><p className="text-xs text-gray-500">Acknowledgement Number</p><p className="text-sm font-medium">{form16.acknowledgementNumber || ''}</p></div>
<div><p className="text-xs text-gray-500">Date of Issue</p><p className="text-sm font-medium">{form16.dateOfIssue || ''}</p></div>
<div><p className="text-xs text-gray-500">Quarter</p><p className="text-sm font-medium">{form16.quarter ? `${form16.quarter} (${form16.financialYear || ''})` : ''}</p></div>
</div>
<div>
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Deductor Details</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div><span className="text-gray-500">Name:</span> {form16.deductorName || ''}</div>
<div><span className="text-gray-500">TAN:</span> {form16.tanNumber || ''}</div>
</div>
</div>
{(form16.deduceeName || form16.deduceePan) && (
<div>
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Deductee Details (Dealer)</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{form16.deduceeName && <div><span className="text-gray-500">Name:</span> {form16.deduceeName}</div>}
{form16.deduceePan && <div><span className="text-gray-500">PAN:</span> {form16.deduceePan}</div>}
{form16.deduceeAddress && <div className="sm:col-span-2"><span className="text-gray-500">Address:</span> {form16.deduceeAddress}</div>}
</div>
</div>
)}
<div>
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">TDS Payment Details</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{form16.section && <div><span className="text-gray-500">Section:</span> {form16.section}</div>}
<div><span className="text-gray-500">Amount Paid:</span> {formatInr(form16.totalAmount ?? form16.amountPaid)}</div>
<div><span className="text-gray-500">TDS Deducted:</span> {formatInr(form16.tdsAmount)}</div>
{form16.dateOfPayment && <div><span className="text-gray-500">Date of Payment:</span> {form16.dateOfPayment}</div>}
</div>
</div>
{(form16.bsrCode || form16.challanSerialNo || form16.dateOfDeposit) && (
<div>
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Challan Details</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{form16.bsrCode && <div><span className="text-gray-500">BSR Code:</span> {form16.bsrCode}</div>}
{form16.challanSerialNo && <div><span className="text-gray-500">Challan Serial No.:</span> {form16.challanSerialNo}</div>}
{form16.dateOfDeposit && <div><span className="text-gray-500">Date of Deposit:</span> {form16.dateOfDeposit}</div>}
<div><span className="text-gray-500">Amount:</span> {formatInr(form16.tdsAmount)}</div>
</div>
</div>
)}
<div className="flex justify-between items-center pt-3 border-t border-emerald-200 bg-emerald-100/50 rounded-lg px-3 py-2">
<span className="text-sm font-semibold text-emerald-800">Total Tax Deducted (Form 16A)</span>
<span className="text-sm font-bold text-emerald-800">{formatInr(form16.tdsAmount)}</span>
</div>
</>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><p className="text-xs text-gray-500">Submission</p><p className="text-sm font-medium">{request?.title || 'Form 16A'}</p></div>
</div>
<div className="rounded-lg bg-emerald-100/50 border border-emerald-200 p-3 text-sm text-gray-700">
<p className="font-medium text-emerald-800 mb-1">Request details</p>
<FormattedDescription content={request?.description || ''} className="text-sm" />
</div>
<p className="text-xs text-gray-500">Full certificate details (from uploaded PDF) will appear here when available.</p>
</>
)}
</CardContent>
</Card>
{/* OCR extracted data show for all submissions (including duplicate / mismatch / partial) when backend sends it */}
{ocrData && Object.keys(ocrData).length > 0 && (
<Card data-testid="form16-ocr-extracted-data" className="border-slate-200 bg-slate-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base text-slate-800">
<Scan className="w-4 h-4 text-slate-600" />
OCR extracted data
</CardTitle>
<CardDescription className="text-xs text-gray-600">Data extracted from the uploaded Form 16A PDF (shown for all submission outcomes).</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
{(ocrData.deductorName ?? ocrData.deductor_name ?? ocrData.nameAndAddressOfDeductor ?? ocrData.name_and_address_of_deductor) != null && (
<div><p className="text-xs text-gray-500">Deductor name</p><p className="font-medium">{displayValue(ocrData.deductorName ?? ocrData.deductor_name ?? ocrData.nameAndAddressOfDeductor ?? ocrData.name_and_address_of_deductor)}</p></div>
)}
{(ocrData.tanNumber ?? ocrData.tan_number ?? ocrData.tanOfDeductor ?? ocrData.tan_of_deductor) != null && (
<div><p className="text-xs text-gray-500">TAN</p><p className="font-medium">{displayValue(ocrData.tanNumber ?? ocrData.tan_number ?? ocrData.tanOfDeductor ?? ocrData.tan_of_deductor)}</p></div>
)}
{(ocrData.financialYear ?? ocrData.financial_year) != null && (
<div><p className="text-xs text-gray-500">Financial year</p><p className="font-medium">{displayValue(ocrData.financialYear ?? ocrData.financial_year)}</p></div>
)}
{(ocrData.quarter) != null && (
<div><p className="text-xs text-gray-500">Quarter</p><p className="font-medium">{displayValue(ocrData.quarter)}</p></div>
)}
{(ocrData.form16aNumber ?? ocrData.form16a_number) != null && (
<div><p className="text-xs text-gray-500">Form 16A number</p><p className="font-medium">{displayValue(ocrData.form16aNumber ?? ocrData.form16a_number)}</p></div>
)}
{(ocrData.totalAmount ?? ocrData.total_amount ?? ocrData.totalAmountPaid ?? ocrData.total_amount_paid) != null && (
<div><p className="text-xs text-gray-500">Total amount</p><p className="font-medium">{formatInr((ocrData.totalAmount ?? ocrData.total_amount ?? ocrData.totalAmountPaid ?? ocrData.total_amount_paid) as string | number | null)}</p></div>
)}
{(ocrData.totalTaxDeducted ?? ocrData.total_tax_deducted ?? ocrData.tdsAmount ?? ocrData.tds_amount) != null && (
<div><p className="text-xs text-gray-500">TDS / Tax deducted</p><p className="font-medium">{formatInr((ocrData.totalTaxDeducted ?? ocrData.total_tax_deducted ?? ocrData.tdsAmount ?? ocrData.tds_amount) as string | number | null)}</p></div>
)}
{(ocrData.natureOfPayment ?? ocrData.nature_of_payment) != null && (
<div><p className="text-xs text-gray-500">Nature of payment</p><p className="font-medium">{displayValue(ocrData.natureOfPayment ?? ocrData.nature_of_payment)}</p></div>
)}
{(ocrData.transactionDate ?? ocrData.transaction_date) != null && (
<div><p className="text-xs text-gray-500">Transaction date</p><p className="font-medium">{displayValue(ocrData.transactionDate ?? ocrData.transaction_date)}</p></div>
)}
{(ocrData.assessmentYear ?? ocrData.assessment_year) != null && (
<div><p className="text-xs text-gray-500">Assessment year</p><p className="font-medium">{displayValue(ocrData.assessmentYear ?? ocrData.assessment_year)}</p></div>
)}
{(ocrData.acknowledgementNumber ?? ocrData.acknowledgement_number) != null && (
<div><p className="text-xs text-gray-500">Acknowledgement number</p><p className="font-medium">{displayValue(ocrData.acknowledgementNumber ?? ocrData.acknowledgement_number)}</p></div>
)}
{(ocrData.dateOfIssue ?? ocrData.date_of_issue) != null && (
<div><p className="text-xs text-gray-500">Date of issue</p><p className="font-medium">{displayValue(ocrData.dateOfIssue ?? ocrData.date_of_issue)}</p></div>
)}
{(ocrData.deduceeName ?? ocrData.deducee_name) != null && (
<div><p className="text-xs text-gray-500">Deductee name</p><p className="font-medium">{displayValue(ocrData.deduceeName ?? ocrData.deducee_name)}</p></div>
)}
{(ocrData.deduceePan ?? ocrData.deducee_pan) != null && (
<div><p className="text-xs text-gray-500">Deductee PAN</p><p className="font-medium">{displayValue(ocrData.deduceePan ?? ocrData.deducee_pan)}</p></div>
)}
</div>
</CardContent>
</Card>
)}
{/* Submission context who submitted and when */}
<Card data-testid="form16-submission-context">
<CardHeader className="pb-3">
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
<User className="w-4 h-4 text-emerald-600" />
Submitted by
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-3">
<Avatar className="h-10 w-10 rounded-full flex-shrink-0">
<AvatarFallback className="bg-emerald-100 text-emerald-800 text-sm font-medium">
{request?.initiator?.avatar || 'U'}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="font-medium text-gray-900">{request?.initiator?.name || ''}</p>
<p className="text-xs text-gray-500">{request?.initiator?.role || ''}</p>
{request?.initiator?.email && (
<div className="flex items-center gap-1.5 mt-1 text-xs text-gray-600">
<Mail className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{request.initiator.email}</span>
</div>
)}
</div>
</div>
<div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs text-gray-500 mb-1">Description</p>
<FormattedDescription content={request?.description || ''} className="text-sm text-gray-700" />
</div>
{(request?.createdAt || request?.updatedAt) && (
<div className="mt-2 flex flex-wrap gap-4 text-xs text-gray-500">
{request.createdAt && <span>Created: {new Date(request.createdAt).toLocaleString('en-IN')}</span>}
{request.updatedAt && <span>Last updated: {new Date(request.updatedAt).toLocaleString('en-IN')}</span>}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,205 +0,0 @@
/**
* Form 16 Quick Actions RE actions for the Quick Actions sidebar.
* Shown only for Form 16 requests when RE user and no credit note yet.
* Location: custom (Form 16 only); does not modify shared workflow components.
*/
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, Receipt, X, RotateCcw } from 'lucide-react';
import {
getCreditNoteByRequestId,
cancelForm16Submission,
setForm16ResubmissionNeeded,
generateForm16CreditNoteManually,
} from '@/services/form16Api';
import { toast } from 'sonner';
interface Form16QuickActionsProps {
requestId: string;
request: any;
onRefresh?: () => void;
}
export function Form16QuickActions({ requestId, request, onRefresh }: Form16QuickActionsProps) {
const [creditNote, setCreditNote] = useState<{ id: number; status: string } | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<'cancel' | 'resubmit' | 'credit' | null>(null);
const [generateCnOpen, setGenerateCnOpen] = useState(false);
const [generateCnAmount, setGenerateCnAmount] = useState('');
const form16 = request?.form16Submission;
const hasSubmission = !!form16;
const hasCreditNote = !!creditNote && creditNote.status !== 'withdrawn';
const suggestedAmount = form16?.tdsAmount != null ? Number(form16.tdsAmount) : undefined;
useEffect(() => {
if (!requestId) {
setLoading(false);
return;
}
let cancelled = false;
(async () => {
try {
const note = await getCreditNoteByRequestId(requestId);
if (!cancelled) setCreditNote(note ? { id: typeof note.id === 'number' ? note.id : Number(note.id), status: note.status || '' } : null);
} catch {
if (!cancelled) setCreditNote(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [requestId]);
const handleCancelSubmission = async () => {
if (!requestId || !window.confirm('Cancel this Form 16 submission? The request will be marked as rejected.')) return;
setActionLoading('cancel');
try {
await cancelForm16Submission(requestId);
toast.success('Submission cancelled');
onRefresh?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to cancel submission');
} finally {
setActionLoading(null);
}
};
const handleResubmissionNeeded = async () => {
if (!requestId || !window.confirm('Mark this submission as resubmission needed? The dealer will need to resubmit Form 16.')) return;
setActionLoading('resubmit');
try {
await setForm16ResubmissionNeeded(requestId);
toast.success('Marked as resubmission needed');
onRefresh?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to update');
} finally {
setActionLoading(null);
}
};
const handleGenerateCreditNote = async () => {
const amount = parseFloat(generateCnAmount);
if (!requestId || Number.isNaN(amount) || amount <= 0) {
toast.error('Enter a valid amount to generate credit note');
return;
}
setActionLoading('credit');
try {
await generateForm16CreditNoteManually(requestId, amount);
setGenerateCnOpen(false);
setGenerateCnAmount('');
toast.success('Credit note generated (manually approved)');
const note = await getCreditNoteByRequestId(requestId);
setCreditNote(note ? { id: typeof note.id === 'number' ? note.id : Number(note.id), status: note.status || '' } : null);
onRefresh?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to generate credit note');
} finally {
setActionLoading(null);
}
};
if (loading || !hasSubmission || hasCreditNote) return null;
return (
<>
<Card className="border-blue-200 bg-blue-50/30" data-testid="form16-quick-actions-card">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2 text-blue-800">
<Receipt className="w-4 h-4" />
Form 16 actions
</CardTitle>
<CardDescription className="text-xs text-gray-600">
View the document in the Documents tab. Cancel submission, mark resubmission needed, or generate credit note (e.g. when OCR was partial).
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
size="sm"
className="w-full justify-start border-red-300 text-red-700 hover:bg-red-50"
onClick={handleCancelSubmission}
disabled={!!actionLoading}
>
{actionLoading === 'cancel' ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <X className="w-3 h-3 mr-1" />}
Cancel submission
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start border-amber-300 text-amber-700 hover:bg-amber-50"
onClick={handleResubmissionNeeded}
disabled={!!actionLoading}
>
{actionLoading === 'resubmit' ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <RotateCcw className="w-3 h-3 mr-1" />}
Resubmission needed
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start border-emerald-300 text-emerald-700 hover:bg-emerald-50"
onClick={() => {
setGenerateCnAmount(suggestedAmount != null ? String(suggestedAmount) : '');
setGenerateCnOpen(true);
}}
disabled={!!actionLoading}
>
{actionLoading === 'credit' ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <Receipt className="w-3 h-3 mr-1" />}
Generate credit note
</Button>
</CardContent>
</Card>
<Dialog open={generateCnOpen} onOpenChange={setGenerateCnOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate credit note (manual)</DialogTitle>
<DialogDescription>
Enter the amount for the credit note. This will mark the Form 16 as manually approved.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="form16-cn-amount">Amount ()</Label>
<Input
id="form16-cn-amount"
type="number"
min="0"
step="0.01"
placeholder={suggestedAmount != null ? String(suggestedAmount) : '0'}
value={generateCnAmount}
onChange={(e) => setGenerateCnAmount(e.target.value)}
/>
{suggestedAmount != null && (
<p className="text-xs text-gray-500">Suggested from submission TDS amount: {suggestedAmount.toLocaleString('en-IN')}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGenerateCnOpen(false)} disabled={!!actionLoading}>
Cancel
</Button>
<Button onClick={handleGenerateCreditNote} disabled={!!actionLoading || !generateCnAmount.trim()}>
{actionLoading === 'credit' ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
Generate credit note
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,125 +0,0 @@
/**
* Form 16 Details Workflow tab.
* Shows the Form 16 process: Dealer submits OCR extracts 26AS match Credit note.
* RE actions (cancel, resubmission needed, generate credit note) are in the Quick Actions sidebar.
*/
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CheckCircle, Circle, XCircle, FileText, RefreshCw, Loader2, Receipt } from 'lucide-react';
import { getCreditNoteByRequestId, type Form16CreditNoteItem } from '@/services/form16Api';
interface Form16WorkflowTabProps {
request: any;
requestId: string;
isReUser: boolean;
onRefresh?: () => void;
}
const steps = [
{ key: 'submit', label: 'Dealer submits Form 16', icon: FileText },
{ key: 'ocr', label: 'OCR extracts', icon: FileText },
{ key: 'match', label: 'Matching with 26AS', icon: RefreshCw },
{ key: 'success', label: 'Successfully match', icon: CheckCircle },
{ key: 'credit', label: 'Credit note generated', icon: Receipt },
];
export function Form16WorkflowTab({ request, requestId }: Form16WorkflowTabProps) {
const [creditNote, setCreditNote] = useState<Form16CreditNoteItem | null>(null);
const [loading, setLoading] = useState(true);
const form16 = request?.form16Submission;
const hasSubmission = !!form16;
const hasCreditNote = !!creditNote && creditNote.status !== 'withdrawn';
const creditNoteWithdrawn = !!creditNote && creditNote.status === 'withdrawn';
const validationStatus = form16?.validationStatus ?? null;
const validationFailed = validationStatus === 'failed' || validationStatus === 'mismatch' || validationStatus === 'duplicate';
const validationSuccess = hasCreditNote || validationStatus === 'success' || validationStatus === 'manually_approved';
useEffect(() => {
if (!requestId) {
setLoading(false);
return;
}
let cancelled = false;
(async () => {
try {
const note = await getCreditNoteByRequestId(requestId);
if (!cancelled) setCreditNote(note);
} catch {
if (!cancelled) setCreditNote(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [requestId]);
const stepStatus: Record<string, boolean> = {
submit: true,
ocr: hasSubmission,
match: validationSuccess,
success: validationSuccess,
credit: hasCreditNote || creditNoteWithdrawn,
};
const stepFailed: Record<string, boolean> = {
submit: false,
ocr: false,
match: validationFailed,
success: validationFailed,
credit: false,
};
return (
<div className="space-y-6" data-testid="form16-workflow-tab">
<Card className="border-emerald-200">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2 text-emerald-800">
<RefreshCw className="w-4 h-4" />
Form 16 process
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{steps.map((step) => {
const done = stepStatus[step.key];
const failed = stepFailed[step.key];
return (
<div key={step.key} className="flex items-start gap-3">
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${failed ? 'bg-red-100 text-red-600' : done ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'}`}>
{failed ? <XCircle className="w-4 h-4" /> : done ? <CheckCircle className="w-4 h-4" /> : <Circle className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${failed ? 'text-red-700' : done ? 'text-gray-900' : 'text-gray-500'}`}>{step.label}</p>
{step.key === 'credit' && hasCreditNote && creditNote && (
<p className="text-xs text-gray-500 mt-0.5">
Credit note: {creditNote.creditNoteNumber}
{creditNote.amount != null && ` · ${new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(Number(creditNote.amount))}`}
{validationStatus === 'manually_approved' && (
<span className="ml-1 text-blue-600">(Manually approved Form 16)</span>
)}
</p>
)}
{step.key === 'credit' && creditNoteWithdrawn && (
<p className="text-xs text-amber-600 mt-0.5">Withdrawn</p>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
{loading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
Loading credit note
</div>
)}
{!hasCreditNote && !loading && hasSubmission && (
<p className="text-xs text-gray-500">No credit note has been generated for this submission yet. Use Quick Actions (sidebar) for RE actions.</p>
)}
</div>
);
}

View File

@ -16,8 +16,6 @@
// Request Detail Components // Request Detail Components
export { OverviewTab as CustomOverviewTab } from './components/request-detail/OverviewTab'; export { OverviewTab as CustomOverviewTab } from './components/request-detail/OverviewTab';
export { WorkflowTab as CustomWorkflowTab } from './components/request-detail/WorkflowTab'; export { WorkflowTab as CustomWorkflowTab } from './components/request-detail/WorkflowTab';
export { Form16OverviewTab } from './components/request-detail/Form16OverviewTab';
export { Form16WorkflowTab } from './components/request-detail/Form16WorkflowTab';
// Request Creation Components // Request Creation Components
export { CreateRequest as CustomCreateRequest } from './components/request-creation/CreateRequest'; export { CreateRequest as CustomCreateRequest } from './components/request-creation/CreateRequest';

View File

@ -39,13 +39,9 @@ import { useModalManager } from '@/hooks/useModalManager';
import { downloadDocument } from '@/services/workflowApi'; import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal'; import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { TokenManager } from '@/utils/tokenManager';
// Custom Request Components (import from index to get properly aliased exports) // Custom Request Components (import from index to get properly aliased exports)
import { CustomOverviewTab, CustomWorkflowTab } from '../index'; import { CustomOverviewTab, CustomWorkflowTab } from '../index';
import { Form16OverviewTab } from '../components/request-detail/Form16OverviewTab';
import { Form16WorkflowTab } from '../components/request-detail/Form16WorkflowTab';
import { Form16QuickActions } from '../components/request-detail/Form16QuickActions';
// Shared Components (from src/shared/) // Shared Components (from src/shared/)
import { SharedComponents } from '@/shared/components'; import { SharedComponents } from '@/shared/components';
@ -285,12 +281,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
}; };
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator; const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator); const isClosed = request?.status === 'closed';
const isForm16Request = (request?.templateType || request?.template_type || '').toString().toUpperCase() === 'FORM_16';
useEffect(() => {
if (isForm16Request && activeTab === 'worknotes') setActiveTab('overview');
}, [isForm16Request, activeTab]);
// Fetch summary details if request is closed // Fetch summary details if request is closed
useEffect(() => { useEffect(() => {
@ -418,17 +409,10 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
); );
} }
const isReUser = (user as any)?.role === 'MANAGEMENT' || (user as any)?.role === 'ADMIN';
// Quick Actions and Spectators are for RE users / employees / admins only; hide for dealers
const isDealer = (TokenManager.getUserData() as any)?.jobTitle === 'Dealer';
return ( return (
<> <>
<div className="min-h-screen bg-gray-50" data-testid="custom-request-detail-page"> <div className="min-h-screen bg-gray-50" data-testid="custom-request-detail-page">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{isForm16Request && (
<p className="text-sm text-emerald-700 font-medium mb-2" data-testid="form16-details-heading">Form 16 Details</p>
)}
{/* Header Section */} {/* Header Section */}
<RequestDetailHeader <RequestDetailHeader
request={request} request={request}
@ -452,7 +436,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
data-testid="tab-overview" data-testid="tab-overview"
> >
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" /> <ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">{isForm16Request ? 'Form 16' : 'Overview'}</span> <span className="truncate">Overview</span>
</TabsTrigger> </TabsTrigger>
{isClosed && summaryDetails && ( {isClosed && summaryDetails && (
<TabsTrigger <TabsTrigger
@ -488,31 +472,29 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
<Activity className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" /> <Activity className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Activity</span> <span className="truncate">Activity</span>
</TabsTrigger> </TabsTrigger>
{!isForm16Request && ( <TabsTrigger
<TabsTrigger value="worknotes"
value="worknotes" className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900 relative col-span-2 sm:col-span-1"
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"
data-testid="tab-worknotes" >
> <MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" /> <span className="truncate">Work Notes</span>
<span className="truncate">Work Notes</span> {unreadWorkNotes > 0 && (
{unreadWorkNotes > 0 && ( <Badge
<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"
className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-[10px] flex items-center justify-center p-0" data-testid="worknotes-unread-badge"
data-testid="worknotes-unread-badge" >
> {unreadWorkNotes > 9 ? '9+' : unreadWorkNotes}
{unreadWorkNotes > 9 ? '9+' : unreadWorkNotes} </Badge>
</Badge> )}
)} </TabsTrigger>
</TabsTrigger>
)}
</TabsList> </TabsList>
</div> </div>
{/* Main Layout */} {/* Main Layout */}
<div className={activeTab === 'worknotes' && !isForm16Request ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}> <div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
{/* Left Column: Tab content */} {/* Left Column: Tab content */}
<div className={activeTab === 'worknotes' && !isForm16Request ? '' : 'lg:col-span-2'}> <div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content"> <TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
<CustomOverviewTab <CustomOverviewTab
request={request} request={request}
@ -535,28 +517,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
generationFailed={generationFailed} generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached} maxAttemptsReached={maxAttemptsReached}
/> />
{isForm16Request ? (
<Form16OverviewTab request={request} />
) : (
<CustomOverviewTab
request={request}
isInitiator={isInitiator}
needsClosure={needsClosure}
conclusionRemark={conclusionRemark}
setConclusionRemark={setConclusionRemark}
conclusionLoading={conclusionLoading}
conclusionSubmitting={conclusionSubmitting}
aiGenerated={aiGenerated}
handleGenerateConclusion={handleGenerateConclusion}
handleFinalizeConclusion={handleFinalizeConclusion}
onPause={handlePause}
onResume={handleResume}
onRetrigger={handleRetrigger}
currentUserIsApprover={!!currentApprovalLevel}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
/>
)}
</TabsContent> </TabsContent>
{isClosed && ( {isClosed && (
@ -571,29 +531,20 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
)} )}
<TabsContent value="workflow" className="mt-0"> <TabsContent value="workflow" className="mt-0">
{isForm16Request ? ( <CustomWorkflowTab
<Form16WorkflowTab request={request}
request={request} user={user}
requestId={apiRequest?.requestId || requestIdentifier} isInitiator={isInitiator}
isReUser={isReUser} onSkipApprover={(data) => {
onRefresh={refreshDetails} if (!data.levelId) {
/> alert('Level ID not available');
) : ( return;
<CustomWorkflowTab }
request={request} setSkipApproverData(data);
user={user} setShowSkipApproverModal(true);
isInitiator={isInitiator} }}
onSkipApprover={(data) => { onRefresh={refreshDetails}
if (!data.levelId) { />
alert('Level ID not available');
return;
}
setSkipApproverData(data);
setShowSkipApproverModal(true);
}}
onRefresh={refreshDetails}
/>
)}
</TabsContent> </TabsContent>
<TabsContent value="documents" className="mt-0"> <TabsContent value="documents" className="mt-0">
@ -612,7 +563,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
<ActivityTab request={request} /> <ActivityTab request={request} />
</TabsContent> </TabsContent>
{!isForm16Request && (
<TabsContent value="worknotes" className="mt-0" forceMount={true} hidden={activeTab !== 'worknotes'}> <TabsContent value="worknotes" className="mt-0" forceMount={true} hidden={activeTab !== 'worknotes'}>
<WorkNotesTab <WorkNotesTab
requestId={requestIdentifier} requestId={requestIdentifier}
@ -627,39 +577,28 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })} onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/> />
</TabsContent> </TabsContent>
)}
</div> </div>
{/* Right Column: Quick Actions Sidebar RE users / employees / admins only; hidden for dealers */} {/* Right Column: Quick Actions Sidebar */}
{!isDealer && activeTab !== 'worknotes' && ( {activeTab !== 'worknotes' && (
<div className="space-y-4 sm:space-y-6"> <QuickActionsSidebar
{/* Form 16 RE actions Quick Actions section (Form 16 only, no change to shared workflow) */} request={request}
{isForm16Request && isReUser && ( isInitiator={isInitiator}
<Form16QuickActions isSpectator={isSpectator}
requestId={apiRequest?.requestId || requestIdentifier} currentApprovalLevel={currentApprovalLevel}
request={request} onAddApprover={() => setShowAddApproverModal(true)}
onRefresh={refreshDetails} onAddSpectator={() => setShowAddSpectatorModal(true)}
/> onApprove={() => setShowApproveModal(true)}
)} onReject={() => setShowRejectModal(true)}
<QuickActionsSidebar onPause={handlePause}
request={request} onResume={handleResume}
isInitiator={isInitiator} onRetrigger={handleRetrigger}
isSpectator={isSpectator} summaryId={summaryId}
currentApprovalLevel={currentApprovalLevel} refreshTrigger={sharedRecipientsRefreshTrigger}
onAddApprover={() => setShowAddApproverModal(true)} pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
onAddSpectator={() => setShowAddSpectatorModal(true)} currentUserId={(user as any)?.userId}
onApprove={() => setShowApproveModal(true)} apiRequest={apiRequest}
onReject={() => setShowRejectModal(true)} />
onPause={handlePause}
onResume={handleResume}
onRetrigger={handleRetrigger}
summaryId={summaryId}
refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
apiRequest={apiRequest}
/>
</div>
)} )}
</div> </div>
</Tabs> </Tabs>

View File

@ -334,8 +334,6 @@ export function useRequestDetails(
invoice: (claimDetails as any)?.invoice || null, invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null, creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null, completionExpenses: (claimDetails as any)?.completionExpenses || null,
templateType: wf.templateType || wf.template_type,
form16Submission: details.form16Submission || null,
}; };
setApiRequest(updatedRequest); setApiRequest(updatedRequest);
@ -604,8 +602,6 @@ export function useRequestDetails(
invoice: (claimDetails as any)?.invoice || null, invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null, creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null, completionExpenses: (claimDetails as any)?.completionExpenses || null,
templateType: wf.templateType || wf.template_type,
form16Submission: details.form16Submission || null,
}; };
setApiRequest(mapped); setApiRequest(mapped);

View File

@ -30,8 +30,6 @@ import { DocumentConfig } from '@/components/admin/DocumentConfig';
import { DashboardConfig } from '@/components/admin/DashboardConfig'; import { DashboardConfig } from '@/components/admin/DashboardConfig';
import { AIConfig } from '@/components/admin/AIConfig'; import { AIConfig } from '@/components/admin/AIConfig';
import { SharingConfig } from '@/components/admin/SharingConfig'; import { SharingConfig } from '@/components/admin/SharingConfig';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
interface KPICard { interface KPICard {
id: string; id: string;
@ -292,7 +290,7 @@ export function Admin() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-re-black flex items-center gap-2"> <h1 className="text-re-black flex items-center gap-2">
<Shield className="w-8 h-8 text-re-green" /> <Shield className="w-8 h-8 text-re-green" />
@ -302,24 +300,15 @@ export function Admin() {
Manage users, configure system settings, and control portal behavior Manage users, configure system settings, and control portal behavior
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <Badge className="bg-re-green/10 text-re-green border-re-green/20 px-4 py-2">
<Button <Shield className="w-4 h-4 mr-2" />
variant="outline" Administrator: {user?.displayName || 'Admin User'}
size="sm" </Badge>
onClick={() => toast.info('COB&NDC Coming soon')}
>
COB&NDC
</Button>
<Badge className="bg-re-green/10 text-re-green border-re-green/20 px-4 py-2">
<Shield className="w-4 h-4 mr-2" />
Administrator: {user?.displayName || 'Admin User'}
</Badge>
</div>
</div> </div>
<Separator /> <Separator />
{/* Main tabs */} {/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-2 space-y-4"> <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-2 space-y-4">
<TabsList className="text-muted-foreground items-center justify-center rounded-xl p-[3px] bg-muted grid w-full grid-cols-9 h-auto"> <TabsList className="text-muted-foreground items-center justify-center rounded-xl p-[3px] bg-muted grid w-full grid-cols-9 h-auto">
<TabsTrigger value="kpi" className="flex items-center gap-2"> <TabsTrigger value="kpi" className="flex items-center gap-2">

View File

@ -2,149 +2,162 @@ import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { ArrowRight, Shield } from 'lucide-react'; import { LogIn, Shield } from 'lucide-react';
import { ReLogo, LandingPageImage } from '@/assets'; import { ReLogo, LandingPageImage } from '@/assets';
import { initiateTanflowLogin } from '@/services/tanflowAuth'; import { initiateTanflowLogin } from '@/services/tanflowAuth';
export function Auth() { export function Auth() {
const { login, isLoading, error } = useAuth(); const { login, isLoading, error } = useAuth();
const [tanflowLoading, setTanflowLoading] = useState(false); const [tanflowLoading, setTanflowLoading] = useState(false);
const [tanflowError, setTanflowError] = useState<string | null>(null);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
// Preload the background image
useEffect(() => { useEffect(() => {
const img = new Image(); const img = new Image();
img.src = LandingPageImage; img.src = LandingPageImage;
img.onload = () => setImageLoaded(true); img.onload = () => {
if (img.complete) setImageLoaded(true); setImageLoaded(true);
};
// If image is already cached, trigger load immediately
if (img.complete) {
setImageLoaded(true);
}
}, []); }, []);
const handleOKTALogin = async () => { const handleOKTALogin = async () => {
// Preserve force-reauth flag so Okta gets prompt=login after logout (must re-enter password) // Clear any existing session data
const forceReauth = sessionStorage.getItem('__force_reauth_after_logout__');
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
if (forceReauth) sessionStorage.setItem('__force_reauth_after_logout__', forceReauth);
try { try {
sessionStorage.setItem('auth_provider', 'okta');
await login(); await login();
} catch (loginError) { } catch (loginError) {
console.error('OKTA LOGIN ERROR', loginError); console.error('========================================');
console.error('OKTA LOGIN ERROR');
console.error('Error details:', loginError);
console.error('Error message:', (loginError as Error)?.message);
console.error('Error stack:', (loginError as Error)?.stack);
console.error('========================================');
} }
}; };
const handleTanflowLogin = () => { const handleTanflowLogin = () => {
setTanflowError(null); // Clear any existing session data
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
sessionStorage.setItem('auth_provider', 'tanflow');
setTanflowLoading(true); setTanflowLoading(true);
try { try {
initiateTanflowLogin(); initiateTanflowLogin();
// If no throw, redirect is in progress } catch (loginError) {
} catch (loginError: unknown) { console.error('========================================');
const message = loginError instanceof Error ? loginError.message : 'Dealer login failed. Check console for details.'; console.error('TANFLOW LOGIN ERROR');
console.error('TANFLOW LOGIN ERROR', loginError); console.error('Error details:', loginError);
setTanflowError(message);
setTanflowLoading(false); setTanflowLoading(false);
} }
}; };
if (error) { if (error) {
console.error('Auth Error:', { message: error.message, error }); console.error('Auth Error in Auth Component:', {
message: error.message,
error: error
});
} }
return ( return (
<div <div
className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden" className="min-h-screen flex items-center justify-center p-4 relative"
style={{ style={{
backgroundImage: imageLoaded ? `url(${LandingPageImage})` : 'none', backgroundImage: imageLoaded ? `url(${LandingPageImage})` : 'none',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
transition: 'background-image 0.3s ease-in-out'
}} }}
> >
{/* Fallback background while image loads */}
{!imageLoaded && ( {!imageLoaded && (
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 to-slate-800" /> <div className="absolute inset-0 bg-gradient-to-br from-slate-900 to-slate-800"></div>
)} )}
{/* Blurred overlay so background stays subdued */} {/* Overlay for better readability */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-[2px]" aria-hidden /> <div className="absolute inset-0 bg-black/40"></div>
<div className="absolute inset-0 bg-black/30" aria-hidden />
<Card className="w-full max-w-md shadow-2xl relative z-10 bg-gray-900/95 border border-gray-700 text-white"> <Card className="w-full max-w-md shadow-xl relative z-10 bg-black backdrop-blur-sm border-gray-800">
<CardHeader className="space-y-1 text-center pb-6 pt-8"> <CardHeader className="space-y-1 text-center pb-6">
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center mb-4">
<img <img
src={ReLogo} src={ReLogo}
alt="Royal Enfield" alt="Royal Enfield Logo"
className="h-9 w-auto max-w-[180px] object-contain mb-2" className="h-10 w-auto max-w-[168px] object-contain mb-2"
/> />
<p className="text-sm text-gray-400">Approval Portal</p> <p className="text-xs text-gray-300 text-center truncate">Approval Portal</p>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-5 pb-8 px-8"> <CardContent className="space-y-4">
{error && ( {error && (
<div className="bg-red-900/40 border border-red-700 text-red-200 px-4 py-3 rounded-lg text-sm"> <div className="bg-red-900/50 border border-red-700 text-red-200 px-4 py-3 rounded-lg">
<p className="font-medium">Authentication Error</p> <p className="text-sm font-medium">Authentication Error</p>
<p>{error.message}</p> <p className="text-sm">{error.message}</p>
{(error.message?.includes('401') || error.message?.toLowerCase().includes('unauthorized')) && ( </div>
<p className="mt-2 text-xs text-red-300"> )}
If you see 401 from Okta: your Okta admin must add this sites URL to <strong>Trusted Origins</strong> and ensure the RE Employee application is active. Use <strong>Dealer Login</strong> if you are a dealer.
</p> <div className="space-y-3">
<Button
onClick={handleOKTALogin}
disabled={isLoading || tanflowLoading}
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
size="lg"
>
{isLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Logging in...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
RE Employee Login
</>
)} )}
</div> </Button>
)}
{tanflowError && (
<div className="bg-amber-900/40 border border-amber-700 text-amber-200 px-4 py-3 rounded-lg text-sm">
<p className="font-medium">Dealer Login (Tanflow)</p>
<p>{tanflowError}</p>
</div>
)}
<Button <div className="relative">
onClick={handleOKTALogin} <div className="absolute inset-0 flex items-center">
disabled={isLoading || tanflowLoading} <span className="w-full border-t border-gray-700"></span>
className="w-full h-12 bg-re-red hover:bg-re-red/90 text-white font-semibold text-base border-0" </div>
size="lg" <div className="relative flex justify-center text-xs uppercase">
> <span className="bg-gray-900 px-2 text-gray-400">Or</span>
{isLoading ? ( </div>
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" /> </div>
) : (
<>
<ArrowRight className="mr-2 h-5 w-5" />
RE Employee Login
</>
)}
</Button>
<div className="flex items-center gap-3"> <Button
<span className="flex-1 h-px bg-gray-600" /> onClick={handleTanflowLogin}
<span className="text-sm text-gray-400 uppercase tracking-wide">Or</span> disabled={isLoading || tanflowLoading}
<span className="flex-1 h-px bg-gray-600" /> className="w-full h-12 text-base font-semibold bg-indigo-600 hover:bg-indigo-700 text-white"
size="lg"
>
{tanflowLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Redirecting...
</>
) : (
<>
<Shield className="mr-2 h-5 w-5" />
Dealer Login
</>
)}
</Button>
</div> </div>
<Button <div className="text-center text-sm text-gray-400 mt-4">
onClick={handleTanflowLogin} <p>Secure Single Sign-On</p>
disabled={isLoading || tanflowLoading} <p className="text-xs mt-1 text-gray-500">Choose your authentication provider</p>
className="w-full h-12 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-base border-0"
size="lg"
>
{tanflowLoading ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<>
<Shield className="mr-2 h-5 w-5" />
Dealer Login
</>
)}
</Button>
<div className="text-center pt-2">
<p className="text-sm text-gray-400">Secure Single Sign On</p>
<p className="text-xs text-gray-500 mt-1">Choose your authentication provider.</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -45,84 +45,103 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
}, [userFilterType]); }, [userFilterType]);
const isDealer = userFilterType === 'DEALER'; const isDealer = userFilterType === 'DEALER';
const toApiFilters = useCallback(() => {
const f = filters.getFilters();
return {
search: f.search || undefined,
status: f.status !== 'all' ? f.status : undefined,
priority: !isDealer && f.priority ? f.priority : undefined,
templateType: f.templateType,
financialYear: f.financialYear,
quarter: f.quarter,
sortBy: f.sortBy,
sortOrder: f.sortOrder,
};
}, [filters, isDealer]);
const prevFiltersRef = useRef({ const prevFiltersRef = useRef({
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter, templateTypeFilter: filters.templateTypeFilter,
form16FinancialYear: filters.form16FinancialYear,
form16Quarter: filters.form16Quarter,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
const hasInitialFetchRun = useRef(false); const hasInitialFetchRun = useRef(false);
// Initial fetch on mount - use stored page from Redux
useEffect(() => { useEffect(() => {
const storedPage = filters.currentPage || 1; const storedPage = filters.currentPage || 1;
fetchRef.current(storedPage, toApiFilters()); fetchRef.current(storedPage, {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
// Only include priority and templateType filters if user is not a dealer
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
hasInitialFetchRun.current = true; hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDealer]); }, [isDealer]); // Re-fetch if dealer status changes
// Track filter changes and refetch
useEffect(() => { useEffect(() => {
if (!hasInitialFetchRun.current) return; if (!hasInitialFetchRun.current) return;
const prev = prevFiltersRef.current; const prev = prevFiltersRef.current;
const hasChanged = const hasChanged =
prev.searchTerm !== filters.searchTerm || prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter || prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter || prev.priorityFilter !== filters.priorityFilter ||
prev.templateTypeFilter !== filters.templateTypeFilter || prev.templateTypeFilter !== filters.templateTypeFilter ||
prev.form16FinancialYear !== filters.form16FinancialYear ||
prev.form16Quarter !== filters.form16Quarter ||
prev.sortBy !== filters.sortBy || prev.sortBy !== filters.sortBy ||
prev.sortOrder !== filters.sortOrder; prev.sortOrder !== filters.sortOrder;
if (!hasChanged) return;
if (!hasChanged) return; // No actual change, skip
// Debounce search
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
filters.setCurrentPage(1); filters.setCurrentPage(1); // Reset to page 1 when filters change
fetchRef.current(1, toApiFilters()); fetchRef.current(1, {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
// Update previous values
prevFiltersRef.current = { prevFiltersRef.current = {
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter, templateTypeFilter: filters.templateTypeFilter,
form16FinancialYear: filters.form16FinancialYear,
form16Quarter: filters.form16Quarter,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}; };
}, filters.searchTerm !== prev.searchTerm ? 500 : 0); }, filters.searchTerm !== prev.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.form16FinancialYear, filters.form16Quarter, filters.sortBy, filters.sortOrder, isDealer]); }, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
// Page change handler
const handlePageChange = useCallback( const handlePageChange = useCallback(
(newPage: number) => { (newPage: number) => {
if (newPage >= 1 && newPage <= closedRequests.pagination.totalPages) { if (newPage >= 1 && newPage <= closedRequests.pagination.totalPages) {
filters.setCurrentPage(newPage); filters.setCurrentPage(newPage); // Update page in Redux
closedRequests.fetchRequests(newPage, toApiFilters()); closedRequests.fetchRequests(newPage, {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
} }
}, },
[closedRequests, filters, toApiFilters] [closedRequests, filters]
); );
// Refresh handler
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
closedRequests.handleRefresh(toApiFilters()); closedRequests.handleRefresh({
}, [closedRequests, toApiFilters]); search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
}, [closedRequests, filters]);
return ( return (
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="closed-requests-page"> <div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="closed-requests-page">
@ -140,17 +159,19 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
priorityFilter={filters.priorityFilter} priorityFilter={filters.priorityFilter}
statusFilter={filters.statusFilter} statusFilter={filters.statusFilter}
templateTypeFilter={filters.templateTypeFilter} templateTypeFilter={filters.templateTypeFilter}
form16FinancialYear={filters.form16FinancialYear}
form16Quarter={filters.form16Quarter}
sortBy={filters.sortBy} sortBy={filters.sortBy}
sortOrder={filters.sortOrder} sortOrder={filters.sortOrder}
activeFiltersCount={filters.activeFiltersCount} activeFiltersCount={
isDealer
? // For dealers: only count search and status (closure type)
[filters.searchTerm, filters.statusFilter !== 'all' ? filters.statusFilter : null].filter(Boolean).length
: // For standard users: count all filters
filters.activeFiltersCount
}
onSearchChange={filters.setSearchTerm} onSearchChange={filters.setSearchTerm}
onPriorityChange={filters.setPriorityFilter} onPriorityChange={filters.setPriorityFilter}
onStatusChange={filters.setStatusFilter} onStatusChange={filters.setStatusFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter} onTemplateTypeChange={filters.setTemplateTypeFilter}
onForm16FinancialYearChange={filters.setForm16FinancialYear}
onForm16QuarterChange={filters.setForm16Quarter}
onSortByChange={filters.setSortBy} onSortByChange={filters.setSortBy}
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')} onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
onClearFilters={filters.clearFilters} onClearFilters={filters.clearFilters}

View File

@ -73,9 +73,6 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
if (templateTypeUpper === 'DEALER CLAIM') { if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim'; templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200'; templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'FORM_16') {
templateLabel = 'Form 16';
templateColor = 'bg-emerald-100 !text-emerald-700 border-emerald-200';
} else if (templateTypeUpper === 'TEMPLATE') { } else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template'; templateLabel = 'Template';
} }
@ -97,27 +94,6 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
{request.title} {request.title}
</h4> </h4>
{/* Form 16: dealer, form16a, FY, quarter, total amount, credit note no., status (displayStatus, never Pending) */}
{request.templateType?.toUpperCase() === 'FORM_16' && request.form16Submission && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-600">
{request.form16Submission.dealerCode && (
<span><span className="text-gray-500">Dealer:</span> {request.form16Submission.dealerCode}</span>
)}
{request.form16Submission.form16aNumber && (
<span><span className="text-gray-500">Form 16A:</span> {request.form16Submission.form16aNumber}</span>
)}
{request.form16Submission.financialYear && (
<span><span className="text-gray-500">FY:</span> {request.form16Submission.financialYear}</span>
)}
{request.form16Submission.quarter && (
<span><span className="text-gray-500">Q:</span> {request.form16Submission.quarter}</span>
)}
<span><span className="text-gray-500">Total amount:</span> {request.form16Submission.totalAmount != null ? new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(request.form16Submission.totalAmount) : '—'}</span>
<span><span className="text-gray-500">Credit note:</span> {request.form16Submission.creditNoteNumber || '—'}</span>
<span><span className="text-gray-500">Status:</span> {request.form16Submission.displayStatus || request.form16Submission.status || '—'}</span>
</div>
)}
{/* Metadata Row */} {/* Metadata Row */}
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-600"> <div className="flex flex-wrap items-center gap-4 text-xs text-gray-600">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">

View File

@ -30,13 +30,21 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
}); });
const fetchRequests = useCallback( const fetchRequests = useCallback(
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string }) => { async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
setLoading(true); setLoading(true);
setRequests([]); setRequests([]);
} }
// 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)
// Backend supports filtering by:
// - 'approved': Closed requests where all approvals succeeded (no rejections)
// - 'rejected': Closed requests where at least one approval was rejected
// - 'all': All closed requests regardless of how they were closed
const result = await workflowApi.listClosedByMe({ const result = await workflowApi.listClosedByMe({
page, page,
limit: itemsPerPage, limit: itemsPerPage,
@ -44,8 +52,6 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
status: filters?.status && filters.status !== 'all' ? filters.status : undefined, status: filters?.status && filters.status !== 'all' ? filters.status : undefined,
priority: filters?.priority, priority: filters?.priority,
templateType: filters?.templateType, templateType: filters?.templateType,
financialYear: filters?.financialYear,
quarter: filters?.quarter,
sortBy: filters?.sortBy, sortBy: filters?.sortBy,
sortOrder: filters?.sortOrder sortOrder: filters?.sortOrder
}); });
@ -85,7 +91,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
// Initial fetch removed - component handles initial fetch using Redux stored page // Initial fetch removed - component handles initial fetch using Redux stored page
// This prevents duplicate fetches and allows page persistence // This prevents duplicate fetches and allows page persistence
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string }) => { const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
setRefreshing(true); setRefreshing(true);
fetchRequests(pagination.currentPage, filters); fetchRequests(pagination.currentPage, filters);
}, [fetchRequests, pagination.currentPage]); }, [fetchRequests, pagination.currentPage]);

View File

@ -10,8 +10,6 @@ import {
setStatusFilter as setStatusFilterAction, setStatusFilter as setStatusFilterAction,
setPriorityFilter as setPriorityFilterAction, setPriorityFilter as setPriorityFilterAction,
setTemplateTypeFilter as setTemplateTypeFilterAction, setTemplateTypeFilter as setTemplateTypeFilterAction,
setForm16FinancialYear as setForm16FinancialYearAction,
setForm16Quarter as setForm16QuarterAction,
setSortBy as setSortByAction, setSortBy as setSortByAction,
setSortOrder as setSortOrderAction, setSortOrder as setSortOrderAction,
setCurrentPage as setCurrentPageAction, setCurrentPage as setCurrentPageAction,
@ -28,14 +26,14 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null); const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, form16FinancialYear, form16Quarter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests); // Get filters from Redux
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
// Create setters that dispatch Redux actions
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]); const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]); const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]); const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]); const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
const setForm16FinancialYear = useCallback((value: string) => dispatch(setForm16FinancialYearAction(value)), [dispatch]);
const setForm16Quarter = useCallback((value: string) => dispatch(setForm16QuarterAction(value)), [dispatch]);
const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]); const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]);
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]); const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]); const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
@ -46,12 +44,10 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
status: statusFilter, status: statusFilter,
priority: priorityFilter, priority: priorityFilter,
templateType: templateTypeFilter !== 'all' ? templateTypeFilter : undefined, templateType: templateTypeFilter !== 'all' ? templateTypeFilter : undefined,
financialYear: templateTypeFilter === 'FORM_16' && form16FinancialYear ? form16FinancialYear : undefined,
quarter: templateTypeFilter === 'FORM_16' && form16Quarter ? form16Quarter : undefined,
sortBy, sortBy,
sortOrder, sortOrder,
}; };
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, form16FinancialYear, form16Quarter, sortBy, sortOrder]); }, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder]);
// Debounced filter change handler // Debounced filter change handler
useEffect(() => { useEffect(() => {
@ -78,7 +74,7 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
clearTimeout(debounceTimeoutRef.current); clearTimeout(debounceTimeoutRef.current);
} }
}; };
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, form16FinancialYear, form16Quarter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]); }, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
dispatch(clearFiltersAction()); dispatch(clearFiltersAction());
@ -88,9 +84,7 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
searchTerm, searchTerm,
priorityFilter !== 'all' ? priorityFilter : null, priorityFilter !== 'all' ? priorityFilter : null,
statusFilter !== 'all' ? statusFilter : null, statusFilter !== 'all' ? statusFilter : null,
templateTypeFilter !== 'all' ? templateTypeFilter : null, templateTypeFilter !== 'all' ? templateTypeFilter : null
templateTypeFilter === 'FORM_16' && form16FinancialYear ? 'fy' : null,
templateTypeFilter === 'FORM_16' && form16Quarter ? 'q' : null,
].filter(Boolean).length; ].filter(Boolean).length;
return { return {
@ -98,8 +92,6 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter, templateTypeFilter,
form16FinancialYear,
form16Quarter,
sortBy, sortBy,
sortOrder, sortOrder,
currentPage, currentPage,
@ -107,8 +99,6 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
setPriorityFilter, setPriorityFilter,
setStatusFilter, setStatusFilter,
setTemplateTypeFilter, setTemplateTypeFilter,
setForm16FinancialYear,
setForm16Quarter,
setSortBy, setSortBy,
setSortOrder, setSortOrder,
setCurrentPage, setCurrentPage,

View File

@ -5,8 +5,6 @@ export interface ClosedRequestsFiltersState {
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
form16FinancialYear: string;
form16Quarter: string;
sortBy: 'created' | 'due' | 'priority'; sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
currentPage: number; currentPage: number;
@ -17,8 +15,6 @@ const initialState: ClosedRequestsFiltersState = {
statusFilter: 'all', statusFilter: 'all',
priorityFilter: 'all', priorityFilter: 'all',
templateTypeFilter: 'all', templateTypeFilter: 'all',
form16FinancialYear: '',
form16Quarter: '',
sortBy: 'created', sortBy: 'created',
sortOrder: 'desc', sortOrder: 'desc',
currentPage: 1, currentPage: 1,
@ -40,12 +36,6 @@ const closedRequestsSlice = createSlice({
setTemplateTypeFilter: (state, action: PayloadAction<string>) => { setTemplateTypeFilter: (state, action: PayloadAction<string>) => {
state.templateTypeFilter = action.payload; state.templateTypeFilter = action.payload;
}, },
setForm16FinancialYear: (state, action: PayloadAction<string>) => {
state.form16FinancialYear = action.payload;
},
setForm16Quarter: (state, action: PayloadAction<string>) => {
state.form16Quarter = action.payload;
},
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => { setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => {
state.sortBy = action.payload; state.sortBy = action.payload;
}, },
@ -60,8 +50,6 @@ const closedRequestsSlice = createSlice({
state.statusFilter = 'all'; state.statusFilter = 'all';
state.priorityFilter = 'all'; state.priorityFilter = 'all';
state.templateTypeFilter = 'all'; state.templateTypeFilter = 'all';
state.form16FinancialYear = '';
state.form16Quarter = '';
state.currentPage = 1; state.currentPage = 1;
}, },
}, },
@ -72,8 +60,6 @@ export const {
setStatusFilter, setStatusFilter,
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter, setTemplateTypeFilter,
setForm16FinancialYear,
setForm16Quarter,
setSortBy, setSortBy,
setSortOrder, setSortOrder,
setCurrentPage, setCurrentPage,

View File

@ -18,18 +18,6 @@ export interface ClosedRequest {
totalLevels?: number; totalLevels?: number;
completedLevels?: number; completedLevels?: number;
templateType?: string; // Template type for badge display templateType?: string; // Template type for badge display
/** Form 16: dealer code, form16a number, FY, quarter, totalAmount, creditNoteNumber, displayStatus */
form16Submission?: {
dealerCode?: string;
form16aNumber?: string;
financialYear?: string;
quarter?: string;
status?: string;
submittedDate?: string;
totalAmount?: number | null;
creditNoteNumber?: string | null;
displayStatus?: string;
} | null;
} }
export interface ClosedRequestsProps { export interface ClosedRequestsProps {
@ -41,8 +29,6 @@ export interface ClosedRequestsFilters {
status: string; status: string;
priority: string; priority: string;
templateType?: string; templateType?: string;
financialYear?: string;
quarter?: string;
sortBy: 'created' | 'due' | 'priority'; sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
} }

View File

@ -29,7 +29,6 @@ export function transformClosedRequest(r: any): ClosedRequest {
totalLevels: r.totalLevels || 0, totalLevels: r.totalLevels || 0,
completedLevels: r.summary?.approvedLevels || 0, completedLevels: r.summary?.approvedLevels || 0,
templateType: r.templateType || r.template_type, // Template type for badge display templateType: r.templateType || r.template_type, // Template type for badge display
form16Submission: r.form16Submission ?? null, // Form 16 columns when incorporated from Form 16 Closed
}; };
} }

View File

@ -1,218 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Mail, Download, Loader2 } from 'lucide-react';
import { getCreditNoteById, type CreditNoteDetailResponse } from '@/services/form16Api';
import { toast } from 'sonner';
function formatDate(value: string | null | undefined): string {
if (!value) return '';
try {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return value;
}
}
function formatAmount(value: number | null | undefined): string {
if (value == null) return '';
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(value);
}
export function Form16CreditNoteDetail() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [data, setData] = useState<CreditNoteDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const numId = id ? parseInt(id, 10) : NaN;
if (Number.isNaN(numId)) {
setLoading(false);
return;
}
let cancelled = false;
getCreditNoteById(numId)
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err) => {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : 'Failed to load credit note');
navigate('/form16/credit-notes');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id, navigate]);
const handleResend = () => {
toast.info('Resend to dealer integration pending');
};
const handleDownload = () => {
toast.info('Download credit note integration pending');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 p-4 md:p-6 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-teal-600" />
</div>
);
}
if (!data) {
return null;
}
const { creditNote, dealerName, dealerEmail, dealerContact, dealerCreditNotes } = data;
const cnNum = creditNote.creditNoteNumber
? creditNote.creditNoteNumber.startsWith('CN')
? creditNote.creditNoteNumber
: `CN #${creditNote.creditNoteNumber}`
: '';
return (
<div className="space-y-6 min-h-screen bg-gray-50 p-4 md:p-6 w-full">
<div className="w-full min-w-0">
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/form16/credit-notes')}
className="text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Credit Notes
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleResend}>
<Mail className="w-4 h-4 mr-2" />
Resend to Dealer
</Button>
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="w-4 h-4 mr-2" />
Download Credit Note
</Button>
</div>
</div>
{/* Main Credit Note Card */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="text-xl font-semibold text-gray-900">{cnNum}</span>
<Badge className="bg-green-100 text-green-800 border-green-200">
{creditNote.status ?? 'Issued'}
</Badge>
</div>
<p className="text-sm text-gray-500">
Issued on {formatDate(creditNote.issueDate)}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Credit Amount</p>
<p className="text-2xl font-semibold text-green-600">
{formatAmount(creditNote.amount)}
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-6 pt-6 border-t border-gray-100">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide">Dealer Name</p>
<p className="text-sm font-medium text-gray-900">{dealerName || ''}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide">Form 16A Number</p>
<p className="text-sm font-medium text-gray-900">{creditNote.submission?.form16aNumber ?? ''}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide">Email</p>
<p className="text-sm text-gray-700">{dealerEmail || ''}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide">Contact</p>
<p className="text-sm text-gray-700">{dealerContact || ''}</p>
</div>
</div>
</CardContent>
</Card>
{/* Dealer Transaction History */}
<Card>
<CardHeader>
<CardTitle>Dealer Transaction History</CardTitle>
<CardDescription>All credit notes issued to {dealerName || 'this dealer'}</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Submission Date</TableHead>
<TableHead>Form 16A No.</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Credit Note Issued</TableHead>
<TableHead>Credit Note No.</TableHead>
<TableHead>CN Issue Date</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dealerCreditNotes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-gray-500">
No other credit notes for this dealer
</TableCell>
</TableRow>
) : (
dealerCreditNotes.map((cn) => (
<TableRow key={cn.id} className="hover:bg-gray-50 transition-colors">
<TableCell>{formatDate(cn.submittedDate)}</TableCell>
<TableCell>{cn.form16aNumber ?? ''}</TableCell>
<TableCell>{formatAmount(cn.amount)}</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800 border-green-200">Yes</Badge>
</TableCell>
<TableCell>
{cn.creditNoteNumber
? cn.creditNoteNumber.startsWith('CN')
? cn.creditNoteNumber
: `CN #${cn.creditNoteNumber}`
: ''}
</TableCell>
<TableCell>{formatDate(cn.issueDate)}</TableCell>
<TableCell>
<Badge
className={
cn.status?.toLowerCase() === 'withdrawn'
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-green-100 text-green-800 border-green-200'
}
>
{cn.status?.toLowerCase() === 'withdrawn' ? 'Withdrawn' : 'Completed'}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,210 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Search, Loader2, Receipt } from 'lucide-react';
import {
listCreditNotes,
type Form16CreditNoteItem,
type ListCreditNotesParams,
type ListCreditNotesSummary,
} from '@/services/form16Api';
import { toast } from 'sonner';
function formatDate(value: string | null | undefined): string {
if (!value) return '';
try {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return value;
}
}
function formatAmount(value: number | null | undefined): string {
if (value == null) return '';
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(value);
}
const DEFAULT_SUMMARY: ListCreditNotesSummary = {
totalCreditNotes: 0,
totalAmount: 0,
activeDealersCount: 0,
};
export function Form16CreditNotes() {
const navigate = useNavigate();
const [creditNotes, setCreditNotes] = useState<Form16CreditNoteItem[]>([]);
const [summary, setSummary] = useState<ListCreditNotesSummary>(DEFAULT_SUMMARY);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const fetchNotes = useCallback(async (params?: ListCreditNotesParams) => {
try {
setLoading(true);
const result = await listCreditNotes(params);
setCreditNotes(result.creditNotes);
setSummary(result.summary ?? DEFAULT_SUMMARY);
} catch (error: unknown) {
console.error('[Form16CreditNotes] Failed to fetch:', error);
toast.error(error instanceof Error ? error.message : 'Failed to load credit notes');
setCreditNotes([]);
setSummary(DEFAULT_SUMMARY);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
const filteredNotes = useMemo(() => {
if (!searchQuery.trim()) return creditNotes;
const q = searchQuery.trim().toLowerCase();
return creditNotes.filter(
(n) =>
(n.creditNoteNumber && n.creditNoteNumber.toLowerCase().includes(q)) ||
(n.dealerName && n.dealerName.toLowerCase().includes(q)) ||
(n.dealerCode && n.dealerCode.toLowerCase().includes(q)) ||
(n.submission?.form16aNumber && n.submission.form16aNumber.toLowerCase().includes(q))
);
}, [creditNotes, searchQuery]);
return (
<div className="space-y-6 min-h-screen bg-gray-50 p-4 md:p-6 w-full">
<div className="w-full min-w-0">
<div className="mb-6">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Credit Notes</h1>
<p className="text-sm text-gray-600">View and manage all issued credit notes</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardHeader className="pb-3">
<CardDescription>Total Credit Notes Issued</CardDescription>
<CardTitle className="text-3xl">
{loading ? '...' : summary.totalCreditNotes}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Total Credit Amount</CardDescription>
<CardTitle className="text-3xl text-green-600">
{loading ? '...' : formatAmount(summary.totalAmount)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Active Dealers</CardDescription>
<CardTitle className="text-3xl">
{loading ? '...' : summary.activeDealersCount}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* All Credit Notes */}
<Card>
<CardHeader>
<CardTitle>All Credit Notes</CardTitle>
<CardDescription className="flex flex-col gap-2">
<span>Search by credit note number, dealer name, or Form 16A number</span>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="search"
placeholder="Search credit notes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Credit Note No.</TableHead>
<TableHead>Dealer Name</TableHead>
<TableHead>Form 16A No.</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Issue Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-teal-600" />
<p className="text-gray-500">Loading credit notes...</p>
</TableCell>
</TableRow>
) : filteredNotes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
<Receipt className="w-12 h-12 mx-auto mb-3 text-gray-400" />
<p>No credit notes found</p>
<p className="text-sm mt-1">
{searchQuery.trim()
? 'Try a different search.'
: 'Credit notes will appear here once issued.'}
</p>
</TableCell>
</TableRow>
) : (
filteredNotes.map((note) => (
<TableRow key={note.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">
{note.creditNoteNumber
? note.creditNoteNumber.startsWith('CN')
? note.creditNoteNumber
: `CN #${note.creditNoteNumber}`
: ''}
</TableCell>
<TableCell>{note.dealerName ?? note.dealerCode ?? ''}</TableCell>
<TableCell>{note.submission?.form16aNumber ?? ''}</TableCell>
<TableCell>{formatAmount(note.amount)}</TableCell>
<TableCell>{formatDate(note.issueDate)}</TableCell>
<TableCell>
<Badge
className={
note.status?.toLowerCase() === 'issued' || note.status?.toLowerCase() === 'completed'
? 'bg-green-100 text-green-800 border-green-200'
: 'bg-gray-100 text-gray-700 border-gray-200'
}
>
{note.status ?? ''}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/form16/credit-notes/${note.id}`)}
>
View Details
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,334 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Bell, Mail, Phone, Calendar, User, Loader2 } from 'lucide-react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { listNonSubmittedDealers, notifyNonSubmittedDealer, type NonSubmittedDealerItem } from '@/services/form16Api';
function formatDisplayDate(isoDate: string | null): string {
if (!isoDate) return '—';
try {
const d = new Date(isoDate + 'Z');
const day = d.getUTCDate();
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[d.getUTCMonth()];
const year = d.getUTCFullYear();
return `${day}-${month}-${year}`;
} catch {
return isoDate;
}
}
const FY_OPTIONS = [
{ value: '2024-25', label: '2024-25' },
{ value: '2023-24', label: '2023-24' },
{ value: '2022-23', label: '2022-23' },
{ value: '', label: 'All Years' },
];
export function Form16NonSubmittedDealers() {
const [notifyingDealer, setNotifyingDealer] = useState<string | null>(null);
const [dealers, setDealers] = useState<NonSubmittedDealerItem[]>([]);
const [summary, setSummary] = useState<{
totalDealers: number;
nonSubmittedCount: number;
neverSubmittedCount: number;
overdue90Count: number;
}>({ totalDealers: 0, nonSubmittedCount: 0, neverSubmittedCount: 0, overdue90Count: 0 });
const [loading, setLoading] = useState(true);
const [financialYearFilter, setFinancialYearFilter] = useState<string>('2024-25');
useEffect(() => {
fetchNonSubmittedDealers();
}, [financialYearFilter]);
const fetchNonSubmittedDealers = async () => {
try {
setLoading(true);
const response = await listNonSubmittedDealers(
financialYearFilter || undefined
);
setDealers(response.dealers);
setSummary(response.summary);
} catch (error) {
console.error('Error fetching non-submitted dealers:', error);
toast.error('Failed to load non-submitted dealers');
setDealers([]);
setSummary({ totalDealers: 0, nonSubmittedCount: 0, neverSubmittedCount: 0, overdue90Count: 0 });
} finally {
setLoading(false);
}
};
const handleNotifyDealer = async (dealer: NonSubmittedDealerItem) => {
setNotifyingDealer(dealer.id);
try {
await notifyNonSubmittedDealer(dealer.dealerCode, financialYearFilter || undefined);
toast.success(`Notification sent to ${dealer.dealerName}`, {
description: `Reminder sent for missing quarters: ${dealer.missingQuarters.join(', ')}. Last notified column updated.`,
});
await fetchNonSubmittedDealers();
} catch (err) {
console.error('Notify dealer error:', err);
toast.error('Failed to send notification');
} finally {
setNotifyingDealer(null);
}
};
return (
<div className="space-y-6 min-h-screen bg-gray-50 p-4 md:p-6 w-full">
<div className="w-full min-w-0">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">
Non-Submitted Dealers
</h1>
<p className="text-sm text-gray-600">
Dealers who have not submitted Form 16A for one or more quarters
</p>
</div>
<div className="flex items-center gap-2">
<select
value={financialYearFilter}
onChange={(e) => setFinancialYearFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white"
>
{FY_OPTIONS.map((opt) => (
<option key={opt.value || 'all'} value={opt.value}>
{opt.value ? `FY ${opt.label}` : opt.label}
</option>
))}
</select>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-3">
<CardDescription>Non-Submitted / Total Dealers</CardDescription>
<CardTitle className="text-3xl text-red-600">
{loading
? '...'
: `${summary.nonSubmittedCount}/${summary.totalDealers}`}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Never Submitted</CardDescription>
<CardTitle className="text-3xl text-amber-600">
{loading ? '...' : summary.neverSubmittedCount}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Overdue (90+ days)</CardDescription>
<CardTitle className="text-3xl text-orange-600">
{loading ? '...' : summary.overdue90Count}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Dealers Table */}
<Card>
<CardHeader>
<CardTitle>Dealer List</CardTitle>
<CardDescription>
Dealers with missing Form 16A submissions
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Dealer Details</TableHead>
<TableHead>Contact Information</TableHead>
<TableHead>Location</TableHead>
<TableHead>Missing Quarters</TableHead>
<TableHead>Last Submission</TableHead>
<TableHead>Last Notified</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-teal-600" />
<p className="text-gray-500">Loading dealers...</p>
</TableCell>
</TableRow>
) : dealers.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
<p>No dealers with missing quarters found</p>
<p className="text-sm mt-1">
All dealers have submitted Form 16A for the selected period
</p>
</TableCell>
</TableRow>
) : (
dealers.map((dealer) => (
<TableRow
key={dealer.id}
className="hover:bg-gray-50 transition-colors"
>
<TableCell>
<div>
<p className="text-sm font-medium">{dealer.dealerName}</p>
<p className="text-xs text-gray-500">{dealer.dealerCode}</p>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<Mail className="w-3.5 h-3.5 shrink-0" />
<span>{dealer.email || '—'}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<Phone className="w-3.5 h-3.5 shrink-0" />
<span>{dealer.phone || '—'}</span>
</div>
</div>
</TableCell>
<TableCell className="text-sm">{dealer.location || '—'}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{dealer.missingQuarters.map((quarter, index) => (
<Badge
key={index}
variant="destructive"
className="text-xs"
>
{quarter}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{dealer.lastSubmissionDate ? (
<div>
<p className="text-sm">{formatDisplayDate(dealer.lastSubmissionDate)}</p>
<p
className={`text-xs ${
dealer.daysSinceLastSubmission != null &&
dealer.daysSinceLastSubmission > 90
? 'text-red-600'
: 'text-gray-500'
}`}
>
{dealer.daysSinceLastSubmission} days ago
</p>
</div>
) : (
<Badge
variant="outline"
className="text-xs bg-red-50 text-red-700 border-red-200"
>
Never Submitted
</Badge>
)}
</TableCell>
<TableCell>
{dealer.lastNotifiedDate ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-1 cursor-help">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<Calendar className="w-3.5 h-3.5 shrink-0" />
<span>{formatDisplayDate(dealer.lastNotifiedDate)}</span>
</div>
{dealer.notificationCount > 1 && (
<Badge
variant="outline"
className="text-xs bg-blue-50 text-blue-700 border-blue-200"
>
{dealer.notificationCount}x
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<User className="w-3.5 h-3.5 shrink-0" />
<span>{dealer.lastNotifiedBy}</span>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<div className="space-y-2">
<p className="text-xs font-medium">
Notification History ({dealer.notificationCount})
</p>
<div className="space-y-1.5">
{dealer.notificationHistory?.map((notification, idx) => (
<div
key={idx}
className="text-xs border-l-2 border-blue-400 pl-2 py-0.5"
>
<div className="flex justify-between gap-3">
<span className="text-gray-600">
{notification.date}
</span>
<Badge
variant="outline"
className="text-xs h-4 px-1"
>
{notification.method}
</Badge>
</div>
<p className="text-gray-500 text-xs">
by {notification.notifiedBy}
</p>
</div>
))}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge
variant="outline"
className="text-xs bg-gray-50 text-gray-600 border-gray-200"
>
Never Notified
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
onClick={() => handleNotifyDealer(dealer)}
disabled={notifyingDealer === dealer.id}
className="bg-teal-600 hover:bg-teal-700 text-white"
>
<Bell className="w-4 h-4 mr-1" />
{notifyingDealer === dealer.id ? 'Sending...' : 'Notify'}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,425 +0,0 @@
/**
* Form 16 Pending Submissions Dealer only.
* Shows quarters for which Form 16A has not been submitted or is pending/failed.
* API-driven: listDealerPendingQuarters, listDealerSubmissions.
*/
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AlertTriangle, Calendar, FileText, Filter, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { listDealerSubmissions, listDealerPendingQuarters, type DealerPendingQuarterItem, type DealerPendingSubmissionItem } from '@/services/form16Api';
import { toast } from 'sonner';
export function Form16PendingSubmissions() {
const navigate = useNavigate();
const [pendingQuarters, setPendingQuarters] = useState<DealerPendingQuarterItem[]>([]);
const [submissions, setSubmissions] = useState<DealerPendingSubmissionItem[]>([]);
const [loading, setLoading] = useState(true);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState({ financialYear: 'all', quarter: 'all' });
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const [quartersRes, subsRes] = await Promise.all([
listDealerPendingQuarters(),
listDealerSubmissions({ status: 'pending,failed,completed' }),
]);
if (!cancelled) {
setPendingQuarters(Array.isArray(quartersRes) ? quartersRes : []);
setSubmissions(Array.isArray(subsRes) ? subsRes : []);
}
} catch (e) {
if (!cancelled) {
toast.error('Failed to load pending submissions');
setPendingQuarters([]);
setSubmissions([]);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
const filteredQuarters = pendingQuarters.filter((q) => {
if (filters.financialYear !== 'all' && q.financial_year !== filters.financialYear) return false;
if (filters.quarter !== 'all' && q.quarter !== filters.quarter) return false;
return true;
});
const totalPending = filteredQuarters.length;
const overdueCount = filteredQuarters.filter((q) => (q.days_overdue ?? 0) > 0).length;
const upcomingCount = filteredQuarters.filter((q) => (q.days_remaining ?? 0) > 0).length;
const clearFilters = () => setFilters({ financialYear: 'all', quarter: 'all' });
const hasActiveFilters = filters.financialYear !== 'all' || filters.quarter !== 'all';
const formatDate = (d: string | null) => {
if (!d) return '—';
try {
const date = new Date(d);
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return d;
}
};
const formatAmount = (amount: number | null | undefined) => {
if (amount == null) return '—';
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(amount);
};
const getStatusBadgeVariant = (displayStatus: string | undefined) => {
const s = (displayStatus || '').toLowerCase();
if (s === 'completed') return 'bg-emerald-50 text-emerald-700 border-emerald-200';
if (s === 'balance mismatch' || s === 'failed') return 'bg-red-50 text-red-700 border-red-200';
if (s === 'resubmission needed' || s === 'duplicate' || s === 'duplicate submission') return 'bg-amber-50 text-amber-700 border-amber-200';
return 'bg-slate-50 text-slate-600 border-slate-200';
};
const handleSubmitNow = (_quarter?: DealerPendingQuarterItem) => {
navigate('/form16/submit');
};
const financialYearOptions = [...new Set(pendingQuarters.map((q) => q.financial_year))].sort();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pending Submissions</h1>
<p className="text-gray-600 mt-1">Quarters for which Form 16A has not been submitted.</p>
</div>
<div className="flex items-center gap-2">
<Button className="bg-teal-600 hover:bg-teal-700" onClick={() => navigate('/form16/submit')}>
New Submission
</Button>
<Button variant="outline" onClick={() => setShowFilters(!showFilters)} className="gap-2">
<Filter className="w-4 h-4" />
Filters
{hasActiveFilters && (
<Badge variant="default" className="ml-1 bg-teal-600 hover:bg-teal-700">
{[filters.financialYear, filters.quarter].filter((v) => v !== 'all').length}
</Badge>
)}
{showFilters ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</Button>
</div>
</div>
{showFilters && (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Filter</CardTitle>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="gap-2 text-gray-600 hover:text-gray-900">
<X className="w-4 h-4" />
Clear All
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Financial Year</Label>
<Select value={filters.financialYear} onValueChange={(v) => setFilters((f) => ({ ...f, financialYear: v }))}>
<SelectTrigger><SelectValue placeholder="All years" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Years</SelectItem>
{financialYearOptions.map((y) => (
<SelectItem key={y} value={y}>{y}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Quarter</Label>
<Select value={filters.quarter} onValueChange={(v) => setFilters((f) => ({ ...f, quarter: v }))}>
<SelectTrigger><SelectValue placeholder="All quarters" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Quarters</SelectItem>
<SelectItem value="Q1">Q1</SelectItem>
<SelectItem value="Q2">Q2</SelectItem>
<SelectItem value="Q3">Q3</SelectItem>
<SelectItem value="Q4">Q4</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
)}
{overdueCount > 0 && (
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">
<span className="font-semibold">{overdueCount} overdue submission{overdueCount > 1 ? 's' : ''} detected.</span>
Please submit Form 16A immediately to avoid penalties.
</p>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 mb-1">Total Pending</p>
<p className="text-2xl font-semibold text-gray-900">{totalPending}</p>
</div>
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<FileText className="w-6 h-6 text-gray-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 mb-1">Overdue</p>
<p className="text-2xl font-semibold text-red-600">{overdueCount}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 mb-1">Upcoming</p>
<p className="text-2xl font-semibold text-amber-600">{upcomingCount}</p>
</div>
<div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center">
<Calendar className="w-6 h-6 text-amber-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Pending Form 16A Submissions</CardTitle>
<CardDescription>Complete list of quarters awaiting submission.</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead>Dealer Name</TableHead>
<TableHead>Financial Year</TableHead>
<TableHead>Quarter</TableHead>
<TableHead>Version</TableHead>
<TableHead>Status</TableHead>
<TableHead>26AS start date</TableHead>
<TableHead>Days Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2" />
<p>Loading...</p>
</TableCell>
</TableRow>
) : filteredQuarters.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
<div className="flex flex-col items-center gap-2">
<FileText className="w-12 h-12 text-gray-300" />
<p>{hasActiveFilters ? 'No quarters match your filters' : 'No pending submissions'}</p>
{hasActiveFilters && (
<Button variant="outline" size="sm" onClick={clearFilters} className="mt-2">Clear Filters</Button>
)}
{!hasActiveFilters && (
<Button size="sm" className="bg-teal-600 hover:bg-teal-700 mt-2" onClick={() => navigate('/form16/submit')}>
New Submission
</Button>
)}
</div>
</TableCell>
</TableRow>
) : (
filteredQuarters.map((row) => (
<TableRow key={`${row.financial_year}|${row.quarter}`} className="hover:bg-gray-50">
<TableCell className="text-sm text-gray-900">{row.dealer_name ?? 'Your submission'}</TableCell>
<TableCell><span className="text-sm text-gray-900">FY {row.financial_year}</span></TableCell>
<TableCell>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">{row.quarter}</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200">
{row.has_submission ? `V1` : '—'}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className={
row.latest_submission_status === 'failed'
? 'bg-red-50 text-red-700 border-red-200'
: row.has_submission
? 'bg-slate-50 text-slate-600 border-slate-200'
: 'bg-gray-100 text-gray-600 border-gray-200'
}>
{row.latest_submission_status === 'failed' ? 'Failed' : row.has_submission ? 'Under review' : 'Not Submitted'}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-700">{formatDate(row.twenty_six_as_start_date ?? row.audit_start_date)}</span>
</div>
</TableCell>
<TableCell>
{row.days_since_26as_uploaded != null && row.days_since_26as_uploaded >= 0 ? (
<span className="text-sm font-medium text-gray-700">due from {row.days_since_26as_uploaded} days</span>
) : (
<span className="text-sm text-gray-500"></span>
)}
</TableCell>
<TableCell className="text-right">
<Button size="sm" className="bg-teal-600 hover:bg-teal-700" onClick={() => handleSubmitNow(row)}>
Submit Now
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Your Form 16 Submissions</CardTitle>
<CardDescription>Requests you have submitted. Total amount and credit note (when issued) are shown. Status is never shown as Pending.</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead>Request ID</TableHead>
<TableHead>Financial Year</TableHead>
<TableHead>Quarter</TableHead>
<TableHead className="text-right">Total Amount</TableHead>
<TableHead>Credit Note No.</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-gray-500">
<Loader2 className="w-6 h-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : submissions.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-gray-500">
No Form 16 submissions yet. Use &quot;New Submission&quot; to submit.
</TableCell>
</TableRow>
) : (
submissions.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-50">
<TableCell className="font-mono text-sm">{row.requestId ? String(row.requestId).slice(0, 8) + '…' : '—'}</TableCell>
<TableCell>FY {row.financial_year}</TableCell>
<TableCell><Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">{row.quarter}</Badge></TableCell>
<TableCell className="text-right font-medium">{formatAmount(row.total_amount)}</TableCell>
<TableCell>{row.credit_note_number ?? '—'}</TableCell>
<TableCell>
<Badge variant="outline" className={getStatusBadgeVariant(row.display_status)}>
{row.display_status ?? (row.status === 'completed' ? 'Completed' : row.status === 'failed' ? 'Failed' : 'Under review')}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">{formatDate(row.submitted_date)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/request/${row.requestId}`)}
>
View Request
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{pendingQuarters.length > 0 && (
<Card className="border-amber-200 bg-amber-50/50">
<CardHeader>
<CardTitle className="text-amber-900">Quarters Pending Submission</CardTitle>
<CardDescription className="text-amber-700">
These quarters don&apos;t have completed Form 16A submissions. Please submit Form 16A for these quarters.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{pendingQuarters.slice(0, 8).map((pq) => (
<div key={`${pq.financial_year}|${pq.quarter}`} className="p-3 bg-white rounded-lg border border-amber-200">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-gray-900">{pq.quarter}</p>
<p className="text-sm text-gray-600">{pq.financial_year}</p>
{pq.has_submission && pq.latest_submission_status && (
<Badge variant="outline" className={`mt-1 text-xs ${
pq.latest_submission_status === 'failed'
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-slate-50 text-slate-600 border-slate-200'
}`}>
{pq.latest_submission_status === 'failed' ? 'Failed' : 'Under review'}
</Badge>
)}
</div>
<Button size="sm" className="bg-teal-600 hover:bg-teal-700" onClick={() => navigate('/form16/submit')}>
Submit
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -1,76 +0,0 @@
/**
* Form 16 submission result screen.
* Shown after dealer submits Form 16A. Status: success | mismatch | duplicate | error (request received).
* State is passed via navigate state; fallback from sessionStorage for refresh.
*/
import { useNavigate, useLocation } from 'react-router-dom';
import { useEffect, useCallback } from 'react';
import { RequestSubmissionSuccess, type SubmissionResultStatus } from './components/RequestSubmissionSuccess';
const RESULT_STATE_KEY = 'form16_submission_result';
export interface Form16ResultState {
status: SubmissionResultStatus;
requestId: string;
requestNumber?: string;
creditNoteNumber?: string | null;
message?: string | null;
}
export function Form16SubmissionResult() {
const navigate = useNavigate();
const location = useLocation();
const state = location.state as Form16ResultState | null;
const stored = (() => {
try {
const raw = sessionStorage.getItem(RESULT_STATE_KEY);
if (!raw) return null;
return JSON.parse(raw) as Form16ResultState;
} catch {
return null;
}
})();
const result = (state?.status || state?.requestId) ? state : stored;
const hasValidResult = !!result?.status;
useEffect(() => {
if (!hasValidResult) return;
sessionStorage.setItem(RESULT_STATE_KEY, JSON.stringify(result));
}, [hasValidResult, result]);
const onComplete = useCallback(() => {
sessionStorage.removeItem(RESULT_STATE_KEY);
navigate('/my-requests', { replace: true });
}, [navigate]);
const onResubmit = useCallback(() => {
sessionStorage.removeItem(RESULT_STATE_KEY);
navigate('/form16/submit', { replace: true });
}, [navigate]);
const onViewRequest = useCallback(() => {
if (!result?.requestNumber) return;
sessionStorage.removeItem(RESULT_STATE_KEY);
navigate(`/request/${result.requestNumber}`, { replace: true });
}, [navigate, result?.requestNumber]);
if (!hasValidResult) {
navigate('/form16/submit', { replace: true });
return null;
}
return (
<RequestSubmissionSuccess
status={result.status}
requestId={result.requestNumber ?? result.requestId}
creditNoteNumber={result.creditNoteNumber}
message={result.message}
onComplete={onComplete}
onResubmit={onResubmit}
onViewRequest={result.requestNumber ? onViewRequest : undefined}
/>
);
}

View File

@ -1,582 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
extractOcr,
createForm16Submission,
listDealerPendingQuarters,
type Form16AExtractedData,
type DealerPendingQuarterItem,
} from '@/services/form16Api';
import { toast } from 'sonner';
import {
FileText,
Loader2,
CheckCircle2,
AlertCircle,
Info,
ArrowLeft,
Eye,
Calendar,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { UploadCard } from './components/UploadCard';
type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
function formatDate(d: string | null | undefined): string {
if (!d) return '—';
try {
const date = new Date(d);
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return String(d);
}
}
function displayValue(value: string | number | null | undefined): React.ReactNode {
if (value === null || value === undefined || value === '') return <span className="text-gray-500 italic">N/A</span>;
if (typeof value === 'number') return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return String(value);
}
function formatDeductorDetails(data: Form16AExtractedData | null): React.ReactNode {
if (!data) return <span className="text-gray-500 italic">N/A</span>;
const name = data.deductorName ?? data.nameAndAddressOfDeductor ?? null;
const address = data.deductorAddress ?? null;
const phone = data.deductorPhone ?? null;
const email = data.deductorEmail ?? null;
const na = <span className="text-gray-500 italic">N/A</span>;
return (
<>
<p><span className="text-gray-500">Name: </span>{name ?? na}</p>
<p><span className="text-gray-500">Address: </span>{address ?? na}</p>
<p><span className="text-gray-500">Number: </span>{phone ?? na}</p>
<p><span className="text-gray-500">Email: </span>{email ?? na}</p>
</>
);
}
export function Form16Submit() {
const navigate = useNavigate();
const [pendingQuarters, setPendingQuarters] = useState<DealerPendingQuarterItem[]>([]);
const [pendingQuartersLoading, setPendingQuartersLoading] = useState(true);
const [financialYear, setFinancialYear] = useState<string>('');
const [quarter, setQuarter] = useState<Quarter | ''>('');
const [file, setFile] = useState<File | null>(null);
const [uploadState, setUploadState] = useState<'idle' | 'extracting' | 'success' | 'error'>('idle');
const [extractedData, setExtractedData] = useState<Form16AExtractedData | null>(null);
const [ocrProvider, setOcrProvider] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [manualTan, setManualTan] = useState('');
const [manualDeductor, setManualDeductor] = useState('');
const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null);
const [showPdfPreview, setShowPdfPreview] = useState(false);
useEffect(() => {
let cancelled = false;
listDealerPendingQuarters()
.then((list) => { if (!cancelled) setPendingQuarters(Array.isArray(list) ? list : []); })
.catch(() => { if (!cancelled) setPendingQuarters([]); })
.finally(() => { if (!cancelled) setPendingQuartersLoading(false); });
return () => { cancelled = true; };
}, []);
const openPdfPreview = useCallback(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setPdfPreviewUrl(url);
setShowPdfPreview(true);
}, [file]);
const closePdfPreview = useCallback(() => {
setShowPdfPreview(false);
if (pdfPreviewUrl) {
URL.revokeObjectURL(pdfPreviewUrl);
setPdfPreviewUrl(null);
}
}, [pdfPreviewUrl]);
const handleFileSelect = useCallback(async (selectedFile: File) => {
if (!selectedFile.name.toLowerCase().endsWith('.pdf')) {
toast.error('Please upload a PDF file');
return;
}
setFile(selectedFile);
setExtractedData(null);
setOcrProvider(null);
setUploadState('extracting');
try {
toast.loading('Extracting data with Google Gemini...', { id: 'form16-ocr' });
const result = await extractOcr(selectedFile);
setExtractedData(result.extractedData);
setOcrProvider(result.ocrProvider || null);
setManualTan(result.extractedData.tanNumber ?? result.extractedData.tanOfDeductor ?? '');
setManualDeductor(result.extractedData.deductorName ?? result.extractedData.nameAndAddressOfDeductor ?? '');
setFinancialYear(result.extractedData.financialYear ?? '');
setQuarter((result.extractedData.quarter as Quarter) ?? '');
setUploadState('success');
toast.success(result.ocrProvider ? `Data extracted via ${result.ocrProvider}` : 'Data extracted', { id: 'form16-ocr' });
} catch (err: unknown) {
setUploadState('error');
const message = err instanceof Error ? err.message : 'Failed to extract data from PDF';
toast.error(message, { id: 'form16-ocr' });
}
}, []);
const handleSubmit = async () => {
if (!financialYear || !quarter) {
toast.error('Financial year and quarter could not be extracted from the PDF. Please ensure you upload a valid Form 16A certificate.');
return;
}
if (!file) {
toast.error('Please upload Form 16A PDF');
return;
}
const form16aNumber = (extractedData?.form16aNumber ?? '').trim() || `F16-${financialYear}-${quarter}-${Date.now()}`;
const tdsAmount = extractedData?.tdsAmount ?? extractedData?.totalTaxDeducted ?? 0;
const totalAmount = extractedData?.totalAmount ?? extractedData?.totalAmountPaid ?? 0;
const tanNumber = (manualTan || (extractedData?.tanNumber ?? extractedData?.tanOfDeductor ?? '')).trim();
const deductorName = (manualDeductor || (extractedData?.deductorName ?? extractedData?.nameAndAddressOfDeductor ?? '')).trim();
if (!tanNumber || !deductorName) {
toast.error('TAN and Deductor name are required. Please enter or correct them below.');
return;
}
setIsSubmitting(true);
try {
const result = await createForm16Submission({
financialYear,
quarter,
form16aNumber,
tdsAmount: Number(tdsAmount) || 0,
totalAmount: Number(totalAmount) || 0,
tanNumber,
deductorName,
file,
extractedData: extractedData ?? undefined,
});
const vStatus = result.validationStatus;
const status =
vStatus === 'success'
? ('success' as const)
: vStatus === 'duplicate'
? ('duplicate' as const)
: vStatus === 'failed'
? ('mismatch' as const)
: ('error' as const);
const duplicateMessage =
status === 'duplicate'
? 'A credit note has already been issued for this financial year and quarter. This submission is recorded as a new version.'
: undefined;
toast.success(status === 'success' ? 'Form 16A matched with 26AS. Credit note generated.' : 'Form 16A submitted successfully');
navigate('/form16/submit/result', {
state: {
status,
requestId: result.requestId,
requestNumber: result.requestNumber,
creditNoteNumber: result.creditNoteNumber ?? undefined,
message: duplicateMessage,
},
});
} catch (err: unknown) {
const res = err && typeof err === 'object' && 'response' in err ? (err as { response?: { status?: number; data?: { message?: string } } }).response : undefined;
const msg = res?.data?.message ?? (err instanceof Error ? err.message : 'Failed to submit');
const isDuplicate = res?.status === 400 && (typeof msg === 'string' && (msg.toLowerCase().includes('duplicate') || msg.toLowerCase().includes('already exist')));
if (isDuplicate) {
navigate('/form16/submit/result', {
state: {
status: 'duplicate' as const,
requestId: '',
message: msg,
},
});
} else {
toast.error(String(msg));
}
} finally {
setIsSubmitting(false);
}
};
const showExtractedCard = Boolean(file);
return (
<div className="min-h-[80vh] w-full bg-gray-50 p-4 md:p-6">
<div className="w-full max-w-7xl mx-auto space-y-6">
{/* Header - always visible */}
<div className="flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)} aria-label="Go back">
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Submit Form 16A</h1>
<p className="text-sm text-gray-600">Upload your TDS certificate for credit reconciliation</p>
</div>
</div>
{/* Quarters Pending Submission same listing as Pending Submissions page */}
<Card>
<CardHeader>
<CardTitle>Quarters Pending Submission</CardTitle>
<CardDescription>
These quarters don&apos;t have completed Form 16A submissions. Please submit Form 16A for these quarters.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead>Dealer Name</TableHead>
<TableHead>Financial Year</TableHead>
<TableHead>Quarter</TableHead>
<TableHead>Version</TableHead>
<TableHead>Status</TableHead>
<TableHead>26AS start date</TableHead>
<TableHead>Days Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingQuartersLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2" />
<p>Loading...</p>
</TableCell>
</TableRow>
) : pendingQuarters.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
<div className="flex flex-col items-center gap-2">
<FileText className="w-12 h-12 text-gray-300" />
<p>No pending submissions. All quarters are complete.</p>
</div>
</TableCell>
</TableRow>
) : (
pendingQuarters.map((row) => (
<TableRow key={`${row.financial_year}|${row.quarter}`} className="hover:bg-gray-50">
<TableCell className="text-sm text-gray-900">{row.dealer_name ?? 'Your submission'}</TableCell>
<TableCell><span className="text-sm text-gray-900">FY {row.financial_year}</span></TableCell>
<TableCell>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">{row.quarter}</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200">
{row.has_submission ? 'V1' : '—'}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className={
row.latest_submission_status === 'failed'
? 'bg-red-50 text-red-700 border-red-200'
: row.has_submission
? 'bg-slate-50 text-slate-600 border-slate-200'
: 'bg-gray-100 text-gray-600 border-gray-200'
}>
{row.latest_submission_status === 'failed' ? 'Failed' : row.has_submission ? 'Under review' : 'Not Submitted'}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-700">{formatDate(row.twenty_six_as_start_date ?? row.audit_start_date)}</span>
</div>
</TableCell>
<TableCell>
{row.days_since_26as_uploaded != null && row.days_since_26as_uploaded >= 0 ? (
<span className="text-sm font-medium text-gray-700">due from {row.days_since_26as_uploaded} days</span>
) : (
<span className="text-sm text-gray-500"></span>
)}
</TableCell>
<TableCell className="text-right">
<Button size="sm" className="bg-teal-600 hover:bg-teal-700" onClick={() => navigate('/form16/submit')}>
Submit Now
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Upload - financial year and quarter are taken from OCR extraction */}
<Card>
<CardHeader>
<CardTitle>Upload Form 16A Certificate</CardTitle>
<CardDescription>PDF only. Financial year and quarter are taken from the certificate via OCR; no need to enter them manually.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<UploadCard
state={uploadState === 'extracting' ? 'extracting' : uploadState === 'error' ? 'error' : uploadState === 'success' ? 'success' : 'idle'}
onFileSelect={handleFileSelect}
disabled={uploadState === 'extracting'}
/>
{file && (
<div className={`flex items-center justify-between p-3 rounded-lg border ${
uploadState === 'extracting' ? 'bg-blue-50 border-blue-200' :
uploadState === 'error' ? 'bg-red-50 border-red-200' : 'bg-green-50 border-green-200'
}`}>
<div className="flex items-center gap-2">
{uploadState === 'extracting' ? (
<Loader2 className="w-5 h-5 text-blue-600 shrink-0 animate-spin" />
) : uploadState === 'error' ? (
<AlertCircle className="w-5 h-5 text-red-600 shrink-0" />
) : (
<CheckCircle2 className="w-5 h-5 text-green-600 shrink-0" />
)}
<span className="text-sm font-medium">{file.name}</span>
<span className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB
{uploadState === 'extracting' && ' • Extracting...'}
{uploadState === 'error' && ' • Extraction failed'}
{uploadState === 'success' && extractedData && ' • Data extracted'}
</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setFile(null);
setExtractedData(null);
setOcrProvider(null);
setManualTan('');
setManualDeductor('');
setFinancialYear('');
setQuarter('');
setUploadState('idle');
closePdfPreview();
}}
disabled={uploadState === 'extracting'}
>
Remove
</Button>
</div>
)}
{file && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={openPdfPreview}
disabled={uploadState === 'extracting'}
>
<Eye className="w-4 h-4 mr-2" />
View Uploaded Form 16A
</Button>
)}
</CardContent>
</Card>
{/* Important Notes aligned with REform16 */}
<Card className="border-blue-200 bg-blue-50">
<CardHeader>
<CardTitle className="text-blue-900 flex items-center gap-2">
<Info className="w-5 h-5" />
Important Notes
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-blue-800">
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Ensure the Form 16A certificate is issued by the correct deductor (TAN and name must match)</li>
<li>The certificate should match your Form 26AS data from the Income Tax portal</li>
<li>If you submit multiple versions for the same quarter, only the latest approved version will be processed</li>
<li>You can track all submissions in the Requests section (filter by Form 16)</li>
<li>Processing typically takes 23 business days after validation</li>
</ul>
</CardContent>
</Card>
{/* Form 16A Extracted Details below upload; main OCR fields only */}
{showExtractedCard && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
<CardTitle>Form 16A Extracted Details</CardTitle>
</div>
<CardDescription>
{uploadState === 'extracting'
? 'Extracting with Google Gemini (or regex fallback)...'
: extractedData
? (ocrProvider ? `Extracted via ${ocrProvider}. Unmapped fields show N/A.` : 'Certificate details extracted from uploaded document.')
: uploadState === 'error'
? 'Extraction failed. You can remove the file and try again.'
: 'Waiting for extraction...'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{uploadState === 'extracting' ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mr-3" />
<p className="text-gray-600">Extracting data...</p>
</div>
) : uploadState === 'error' ? (
<div className="flex items-center justify-center py-8">
<AlertCircle className="w-8 h-8 text-amber-600 mr-3 shrink-0" />
<p className="text-gray-600">Data extraction failed. Please remove the file and try uploading again.</p>
</div>
) : extractedData ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<p className="text-sm text-gray-500">Deductor details</p>
<div className="text-sm mt-1 font-medium space-y-0.5">
{formatDeductorDetails(extractedData)}
</div>
</div>
<div>
<p className="text-sm text-gray-500">TAN of the deductor</p>
<p className="text-sm mt-1 font-medium">{displayValue(extractedData.tanOfDeductor ?? extractedData.tanNumber)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Total (Rs)</p>
<p className="text-sm mt-1 font-medium">
{(extractedData.totalAmountPaid ?? extractedData.totalAmount) != null
? `${displayValue(extractedData.totalAmountPaid ?? extractedData.totalAmount)}`
: displayValue(null)}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Total tax deducted</p>
<p className="text-sm mt-1 font-medium">
{(extractedData.totalTaxDeducted ?? extractedData.tdsAmount) != null
? `${displayValue(extractedData.totalTaxDeducted ?? extractedData.tdsAmount)}`
: displayValue(null)}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Total TDS deposited</p>
<p className="text-sm mt-1 font-medium">
{(extractedData.totalTdsDeposited ?? extractedData.tdsAmount) != null
? `${displayValue(extractedData.totalTdsDeposited ?? extractedData.tdsAmount)}`
: displayValue(null)}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Nature of payment</p>
<p className="text-sm mt-1 font-medium">{displayValue(extractedData.natureOfPayment)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Transaction date</p>
<p className="text-sm mt-1 font-medium">{displayValue(extractedData.transactionDate)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Status of matching with OLTAS</p>
<p className="text-sm mt-1 font-medium">{displayValue(extractedData.statusOfMatchingOltas)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Date of booking</p>
<p className="text-sm mt-1 font-medium">{displayValue(extractedData.dateOfBooking)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Form 16 Assessment Year</p>
<p className="text-sm mt-1 font-medium">{displayValue(extractedData.assessmentYear)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Quarter</p>
<p className="text-sm mt-1 font-medium">{displayValue(extractedData.quarter)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Certificate No / Form 16A No</p>
<p className="text-sm mt-1 font-medium">{displayValue(extractedData.form16aNumber)}</p>
</div>
</div>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium text-gray-700">Correct if needed (required for submit)</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="form16-tan">TAN of deductor *</Label>
<Input
id="form16-tan"
value={manualTan}
onChange={(e) => setManualTan(e.target.value)}
placeholder="e.g. BLRH07660C"
maxLength={10}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="form16-deductor">Deductor name *</Label>
<Input
id="form16-deductor"
value={manualDeductor}
onChange={(e) => setManualDeductor(e.target.value)}
placeholder="Name and address of deductor"
/>
</div>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-green-50 border border-green-200 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
<div className="text-sm text-green-800">
<p className="font-medium">Data extracted</p>
<p className="mt-1">
These values will be validated on submit. You can correct TAN and Deductor name above if needed.
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={isSubmitting}>
Cancel
</Button>
<Button
type="button"
className="bg-re-green hover:bg-re-green/90"
onClick={handleSubmit}
disabled={!financialYear || !quarter || isSubmitting}
>
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
Submit Form 16A
</Button>
</div>
</>
) : null}
</CardContent>
</Card>
)}
{/* PDF Preview Dialog */}
<Dialog open={showPdfPreview} onOpenChange={(open) => !open && closePdfPreview()}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col" aria-describedby="form16-pdf-description">
<DialogHeader>
<DialogTitle>Form 16A Certificate</DialogTitle>
<DialogDescription id="form16-pdf-description" className="sr-only">
Preview of the uploaded Form 16A PDF certificate
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 rounded border bg-gray-100">
{pdfPreviewUrl && (
<iframe
title="Form 16A PDF"
src={pdfPreviewUrl}
className="w-full h-[70vh] rounded"
/>
)}
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@ -1,854 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Filter,
X,
ChevronDown,
ChevronUp,
Download,
Upload,
FileText,
Loader2,
Search,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import {
list26asEntries,
upload26asFile,
get26asUploadHistory,
type Tds26asEntryItem,
type List26asParams,
type List26asSummary,
type Form1626asUploadHistoryItem,
} from '@/services/form16Api';
import { toast } from 'sonner';
const PAGE_SIZE = 50;
const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'];
const SECTIONS = [
{ value: '194A', label: '194A - Interest' },
{ value: '194C', label: '194C - Contractor' },
{ value: '194J', label: '194J - Professional' },
{ value: '194H', label: '194H - Commission' },
{ value: '194Q', label: '194Q' },
{ value: '206CH', label: '206CH' },
];
function formatDate(v: string | null | undefined): string {
if (!v) return '';
try {
const d = new Date(v);
return Number.isNaN(d.getTime())
? v
: d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return v;
}
}
function formatCurrency(value: number | null | undefined): string {
if (value == null) return '';
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
}).format(value);
}
function generateFinancialYears(): string[] {
const y = new Date().getFullYear();
const out: string[] = [];
for (let i = 0; i < 6; i++) {
const start = y - i;
const end = (start + 1).toString().slice(-2);
out.push(`${start}-${end}`);
}
return out;
}
function getStatusBadge(status: string | null | undefined) {
const s = (status || '').trim().toUpperCase().slice(0, 1);
switch (s) {
case 'F':
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
F
</Badge>
);
case 'O':
return (
<Badge className="bg-orange-100 text-orange-700 border-orange-300">
O
</Badge>
);
case 'M':
return (
<Badge className="bg-blue-100 text-blue-700 border-blue-300">
M
</Badge>
);
case 'U':
return (
<Badge className="bg-red-100 text-red-700 border-red-300">
U
</Badge>
);
case 'P':
return (
<Badge className="bg-amber-100 text-amber-700 border-amber-300">
P
</Badge>
);
default:
return status ? (
<Badge variant="secondary">{status}</Badge>
) : null;
}
}
type FilterStatus = 'all' | 'F' | 'O' | 'P';
export function Form16_26AS() {
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState({
assessmentYear: 'all',
quarter: 'all',
section: 'all',
deductor: '',
status: 'all' as FilterStatus,
});
const [searchInput, setSearchInput] = useState('');
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showUploadDialog, setShowUploadDialog] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [processingProgress, setProcessingProgress] = useState(0);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const processingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [entries, setEntries] = useState<Tds26asEntryItem[]>([]);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<List26asSummary>({
totalRecords: 0,
booked: 0,
notBooked: 0,
pending: 0,
totalTaxDeducted: 0,
});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [uploadHistory, setUploadHistory] = useState<Form1626asUploadHistoryItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const financialYears = generateFinancialYears();
const fetchUploadHistory = useCallback(async () => {
try {
setLoadingHistory(true);
const history = await get26asUploadHistory(50);
setUploadHistory(history);
} catch (err) {
console.error('[Form16_26AS] upload history', err);
setUploadHistory([]);
} finally {
setLoadingHistory(false);
}
}, []);
const fetchEntries = useCallback(
async (pageIndex: number = 0) => {
try {
setLoading(true);
const params: List26asParams = {
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
};
if (filters.assessmentYear !== 'all') {
params.financialYear = filters.assessmentYear;
}
if (filters.quarter !== 'all') params.quarter = filters.quarter;
if (filters.section !== 'all') params.sectionCode = filters.section;
if (filters.deductor.trim()) params.search = filters.deductor.trim();
if (filters.status !== 'all') params.status = filters.status;
const result = await list26asEntries(params);
setEntries(result.entries);
setTotal(result.total);
setSummary(result.summary);
setPage(pageIndex);
} catch (err: unknown) {
console.error('[Form16_26AS]', err);
toast.error(err instanceof Error ? err.message : 'Failed to load 26AS entries');
setEntries([]);
setTotal(0);
setSummary({
totalRecords: 0,
booked: 0,
notBooked: 0,
pending: 0,
totalTaxDeducted: 0,
});
} finally {
setLoading(false);
}
},
[filters.assessmentYear, filters.quarter, filters.section, filters.deductor, filters.status]
);
useEffect(() => {
fetchEntries(0);
}, [fetchEntries]);
useEffect(() => {
fetchUploadHistory();
}, [fetchUploadHistory]);
useEffect(() => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = setTimeout(() => {
setFilters((f) => ({ ...f, deductor: searchInput.trim() }));
}, 300);
return () => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
};
}, [searchInput]);
useEffect(() => {
return () => {
if (processingIntervalRef.current) {
clearInterval(processingIntervalRef.current);
processingIntervalRef.current = null;
}
};
}, []);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.size > 50 * 1024 * 1024) {
toast.error('File size must be less than 50MB');
return;
}
if (!file.name.toLowerCase().endsWith('.txt')) {
toast.error('Only .txt files are supported');
return;
}
setSelectedFile(file);
}
};
const handleUpload = async () => {
if (!selectedFile) {
toast.error('Please select a file');
return;
}
setUploading(true);
setUploadProgress(0);
setProcessingProgress(0);
if (processingIntervalRef.current) {
clearInterval(processingIntervalRef.current);
processingIntervalRef.current = null;
}
try {
const result = await upload26asFile(selectedFile, (p) => {
setUploadProgress(p);
if (p >= 100 && !processingIntervalRef.current) {
setProcessingProgress(0);
let tick = 0;
processingIntervalRef.current = setInterval(() => {
tick += 1;
setProcessingProgress((prev) => Math.min(99, prev + 4 + Math.floor(tick / 3)));
}, 300);
}
});
if (processingIntervalRef.current) {
clearInterval(processingIntervalRef.current);
processingIntervalRef.current = null;
}
setProcessingProgress(100);
if (result.imported > 0) {
toast.success(
`Successfully imported ${result.imported} entries.${result.errors?.length ? ` ${result.errors.length} warnings.` : ''}`
);
setShowUploadDialog(false);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
fetchEntries(0);
fetchUploadHistory();
} else if (result.errors?.length) {
toast.error(result.errors[0] || 'Upload failed');
}
} catch (err: unknown) {
if (processingIntervalRef.current) {
clearInterval(processingIntervalRef.current);
processingIntervalRef.current = null;
}
toast.error(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
setUploadProgress(0);
setProcessingProgress(0);
}
};
const clearFilters = () => {
setFilters({
assessmentYear: 'all',
quarter: 'all',
section: 'all',
deductor: '',
status: 'all',
});
setSearchInput('');
};
const hasActiveFilters =
filters.assessmentYear !== 'all' ||
filters.quarter !== 'all' ||
filters.section !== 'all' ||
filters.deductor !== '' ||
filters.status !== 'all';
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const canPrev = page > 0;
const canNext = page < totalPages - 1;
// Page numbers to show (sliding window of up to 9)
const paginationStart = Math.max(0, Math.min(page - 4, totalPages - 9));
const paginationEnd = Math.min(totalPages - 1, paginationStart + 8);
const pageNumbers = Array.from(
{ length: paginationEnd - paginationStart + 1 },
(_, i) => paginationStart + i
);
return (
<div className="min-h-screen bg-gray-50 p-3 sm:p-4 md:p-6 overflow-x-hidden">
<div className="w-full max-w-[98vw] xl:max-w-[1920px] mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold text-gray-900 mb-1">Form 26AS - TDS Data</h2>
<p className="text-gray-600 text-sm">View and manage TDS deduction records from Form 26AS</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[180px] max-w-[280px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="Search..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 h-9"
/>
</div>
<Button
onClick={() => setShowUploadDialog(true)}
className="gap-2 bg-teal-600 hover:bg-teal-700 text-white"
>
<Upload className="w-4 h-4" />
Upload 26AS File
</Button>
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="gap-2"
>
<Filter className="w-4 h-4" />
Filters
{hasActiveFilters && (
<Badge className="ml-1 bg-teal-600 hover:bg-teal-700 text-white">
{[filters.assessmentYear, filters.quarter, filters.section, filters.deductor, filters.status].filter(
(v) => v !== '' && v !== 'all'
).length}
</Badge>
)}
{showFilters ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Upload Dialog */}
<Dialog open={showUploadDialog} onOpenChange={setShowUploadDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload 26AS Data File
</DialogTitle>
<p className="text-sm text-muted-foreground">
Upload a 26AS text file (.txt) to import TDS data. Large files will be processed in batches.
</p>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="file-upload">Select 26AS File</Label>
<div className="flex items-center gap-4">
<input
ref={fileInputRef}
id="file-upload"
type="file"
accept=".txt"
onChange={handleFileSelect}
disabled={uploading}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="gap-2"
>
<FileText className="w-4 h-4" />
Choose File
</Button>
{selectedFile && (
<span className="text-sm text-gray-700 truncate">
{selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
</span>
)}
</div>
</div>
{uploading && (
<div className="space-y-5">
{/* Phase 1: Uploading file to server */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">1. Uploading file to server</p>
<div className="flex justify-between text-sm text-muted-foreground">
<span>Sending your file to the server</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
{/* Phase 2: Processing & saving to database (after upload reaches 100%) */}
{uploadProgress >= 100 && (
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">2. Processing & saving to database</p>
<div className="flex justify-between text-sm text-muted-foreground">
<span>Parsing 26AS data and saving TDS records</span>
<span>{Math.round(processingProgress)}%</span>
</div>
<Progress value={processingProgress} className="h-2" indicatorClassName="bg-teal-600" />
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowUploadDialog(false);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}}
disabled={uploading}
>
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={!selectedFile || uploading}
className="bg-teal-600 hover:bg-teal-700 gap-2"
>
{uploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Management 26AS upload history */}
<Card>
<CardHeader>
<CardTitle>Management</CardTitle>
<CardDescription>
History of 26AS file uploads: when and which user added or uploaded 26AS data
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date &amp; Time</TableHead>
<TableHead>User</TableHead>
<TableHead>File Name</TableHead>
<TableHead className="text-right">Records Imported</TableHead>
<TableHead className="text-right">Errors</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingHistory ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-teal-600" />
<p className="text-sm text-gray-500 mt-2">Loading upload history...</p>
</TableCell>
</TableRow>
) : uploadHistory.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
No upload history yet. Uploads will appear here after you add a 26AS file.
</TableCell>
</TableRow>
) : (
uploadHistory.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-50">
<TableCell className="text-sm whitespace-nowrap">
{row.uploadedAt
? new Date(row.uploadedAt).toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: ''}
</TableCell>
<TableCell className="text-sm">
{row.uploadedByDisplayName || row.uploadedByEmail || row.uploadedBy || ''}
</TableCell>
<TableCell className="text-sm text-gray-600 max-w-[200px] truncate" title={row.fileName || undefined}>
{row.fileName || ''}
</TableCell>
<TableCell className="text-right font-medium">{row.recordsImported}</TableCell>
<TableCell className="text-right">{row.errorsCount > 0 ? row.errorsCount : ''}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Filter Panel */}
{showFilters && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Filter TDS Records</CardTitle>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="gap-2 text-gray-600">
<X className="w-4 h-4" />
Clear All
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="space-y-2">
<Label>Assessment Year</Label>
<Select
value={filters.assessmentYear}
onValueChange={(v) => setFilters((f) => ({ ...f, assessmentYear: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select year" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Years</SelectItem>
{financialYears.map((y) => (
<SelectItem key={y} value={y}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Quarter</Label>
<Select
value={filters.quarter}
onValueChange={(v) => setFilters((f) => ({ ...f, quarter: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select quarter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Quarters</SelectItem>
{QUARTERS.map((q) => (
<SelectItem key={q} value={q}>
{q}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Section</Label>
<Select
value={filters.section}
onValueChange={(v) => setFilters((f) => ({ ...f, section: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select section" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sections</SelectItem>
{SECTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Deductor</Label>
<Input
placeholder="Search deductor..."
value={filters.deductor}
onChange={(e) => setFilters((f) => ({ ...f, deductor: e.target.value }))}
className="h-9"
/>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select
value={filters.status}
onValueChange={(v) => setFilters((f) => ({ ...f, status: v as FilterStatus }))}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="F">F Final (Booked)</SelectItem>
<SelectItem value="O">O Overbooked</SelectItem>
<SelectItem value="P">P Pending</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Card>
<CardHeader className="pb-3">
<CardDescription>Total Records</CardDescription>
<CardTitle className="text-2xl">{summary.totalRecords}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Booked</CardDescription>
<CardTitle className="text-2xl text-green-600">{summary.booked}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Not Booked</CardDescription>
<CardTitle className="text-2xl text-red-600">{summary.notBooked}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Pending</CardDescription>
<CardTitle className="text-2xl text-amber-600">{summary.pending}</CardTitle>
</CardHeader>
</Card>
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardDescription>Total Tax Deducted</CardDescription>
<CardTitle className="text-xl">{formatCurrency(summary.totalTaxDeducted)}</CardTitle>
</CardHeader>
</Card>
</div>
{/* TDS Data Table */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<CardTitle>TDS Records from Form 26AS</CardTitle>
<CardDescription>
{hasActiveFilters
? `Showing ${entries.length} of ${total} records`
: `Complete list of TDS deduction records (${total} total)`}
</CardDescription>
</div>
<Button variant="outline" size="sm" disabled>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div
className="overflow-auto min-h-[320px]"
style={{ maxHeight: 'calc(100vh - 380px)' }}
>
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-14">Sr No</TableHead>
<TableHead className="min-w-[180px]">Name of Deductor</TableHead>
<TableHead className="text-right min-w-[110px]">Total Amount Paid</TableHead>
<TableHead className="text-right min-w-[110px]">Total Tax Deducted</TableHead>
<TableHead className="text-right min-w-[110px]">Total TDS Deposited</TableHead>
<TableHead className="min-w-[100px]">TAN of Deductor</TableHead>
<TableHead className="min-w-[72px]">Section</TableHead>
<TableHead className="min-w-[64px]">Quarter</TableHead>
<TableHead className="min-w-[90px]">Assessment Year</TableHead>
<TableHead className="min-w-[90px]">Financial Year</TableHead>
<TableHead className="min-w-[100px]">Transaction Date</TableHead>
<TableHead className="min-w-[64px]">Status</TableHead>
<TableHead className="min-w-[100px]">Date of Booking</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={13} className="text-center py-12">
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-6 h-6 animate-spin text-teal-600" />
<p className="text-gray-500">Loading 26AS data...</p>
</div>
</TableCell>
</TableRow>
) : entries.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="text-center py-12 text-gray-500">
{hasActiveFilters ? 'No records match your filters' : 'No TDS records found. Upload a 26AS file to get started.'}
{hasActiveFilters && (
<Button variant="outline" size="sm" onClick={clearFilters} className="mt-2">
Clear Filters
</Button>
)}
</TableCell>
</TableRow>
) : (
entries.map((entry, idx) => (
<TableRow key={entry.id} className="hover:bg-gray-50">
<TableCell className="text-center text-sm text-gray-900">
{page * PAGE_SIZE + idx + 1}
</TableCell>
<TableCell className="text-sm text-gray-900">{entry.deductorName || ''}</TableCell>
<TableCell className="text-right text-sm text-gray-900">
{formatCurrency(entry.amountPaid ?? 0)}
</TableCell>
<TableCell className="text-right text-sm text-gray-900">
{formatCurrency(entry.taxDeducted)}
</TableCell>
<TableCell className="text-right text-sm text-gray-900">
{formatCurrency(entry.totalTdsDeposited ?? 0)}
</TableCell>
<TableCell className="text-sm text-gray-700 font-mono">{entry.tanNumber}</TableCell>
<TableCell>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
{entry.sectionCode || ''}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200">
{entry.quarter}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-700 font-medium">
{entry.assessmentYear || ''}
</TableCell>
<TableCell className="text-sm text-gray-700 font-medium">
{entry.financialYear || ''}
</TableCell>
<TableCell className="text-sm text-gray-700">
{formatDate(entry.transactionDate)}
</TableCell>
<TableCell>{getStatusBadge(entry.statusOltas)}</TableCell>
<TableCell className="text-sm text-gray-700">
{formatDate(entry.dateOfBooking)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{!loading && total > PAGE_SIZE && (
<div className="flex items-center justify-between mt-4 px-6 pb-6">
<p className="text-sm text-gray-600">
Page {page + 1} of {totalPages} ({total} records)
</p>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-center">
<Button
variant="outline"
size="sm"
disabled={!canPrev}
onClick={() => fetchEntries(page - 1)}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
{pageNumbers.map((pageIndex) => (
<Button
key={pageIndex}
variant={pageIndex === page ? 'default' : 'outline'}
size="sm"
className={pageIndex === page ? 'bg-teal-600 hover:bg-teal-700 text-white min-w-[2rem]' : 'min-w-[2rem]'}
onClick={() => fetchEntries(pageIndex)}
>
{pageIndex + 1}
</Button>
))}
<Button
variant="outline"
size="sm"
disabled={!canNext}
onClick={() => fetchEntries(page + 1)}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,267 +0,0 @@
import { useEffect } from 'react';
import { motion } from 'framer-motion';
import { CheckCircle2, AlertCircle, Ban } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { StatusChip } from './StatusChip';
import { TimelineStep } from './TimelineStep';
export type SubmissionResultStatus = 'success' | 'mismatch' | 'duplicate' | 'error';
interface RequestSubmissionSuccessProps {
/** 'success' = matched & credit note; 'mismatch' = value mismatch; 'duplicate' = already submitted; 'error' = request received / processing */
status: SubmissionResultStatus;
requestId: string;
creditNoteNumber?: string | null;
/** Optional message (e.g. from API or error) */
message?: string | null;
onComplete: () => void;
onResubmit?: () => void;
/** When status is 'error' (request received), optional handler to open the request detail */
onViewRequest?: () => void;
}
export function RequestSubmissionSuccess({
status,
requestId,
creditNoteNumber,
message,
onComplete,
onResubmit,
onViewRequest,
}: RequestSubmissionSuccessProps) {
const isSuccess = status === 'success';
const isMismatch = status === 'mismatch';
const isDuplicate = status === 'duplicate';
const isError = status === 'error';
useEffect(() => {
if (!isSuccess) return;
const timer = setTimeout(onComplete, 5000);
return () => clearTimeout(timer);
}, [isSuccess, onComplete]);
const steps = [
{ label: 'Form 16A Uploaded', state: isDuplicate ? ('failed' as const) : ('completed' as const) },
{ label: 'Validation', state: isDuplicate ? ('failed' as const) : (isError ? ('pending' as const) : ('completed' as const)) },
{ label: '26AS Matching', state: isSuccess ? ('completed' as const) : (isMismatch || isDuplicate) ? ('failed' as const) : ('pending' as const) },
{ label: 'Credit Note', state: isSuccess ? ('completed' as const) : ('pending' as const) },
];
return (
<div className="min-h-[calc(100vh-10rem)] flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className="w-full max-w-2xl"
>
<Card
className={
isSuccess
? 'border-teal-200 shadow-xl'
: isDuplicate
? 'border-red-300 shadow-xl bg-red-50/50'
: isMismatch
? 'border-amber-200 shadow-xl bg-amber-50/30'
: 'border-gray-200 shadow-xl bg-gray-50/30'
}
>
<CardContent className="pt-12 pb-10">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="flex justify-center mb-6"
>
<StatusChip
variant={isSuccess ? 'success' : isDuplicate ? 'failed' : isError ? 'pending' : 'failed'}
label={
isSuccess
? 'Matched & Credit Note Generated'
: isDuplicate
? 'Duplicate Submission'
: isMismatch
? 'Value Mismatch'
: 'Request Received'
}
showIcon={true}
/>
</motion.div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="flex justify-center mb-6"
>
<div
className={`w-20 h-20 rounded-full flex items-center justify-center ${
isSuccess ? 'bg-teal-100' : isDuplicate ? 'bg-red-100' : isError ? 'bg-gray-100' : 'bg-amber-100'
}`}
>
{isSuccess ? (
<CheckCircle2 className="w-12 h-12 text-teal-600" />
) : isDuplicate ? (
<Ban className="w-12 h-12 text-red-600" />
) : isError ? (
<AlertCircle className="w-12 h-12 text-gray-500" />
) : (
<AlertCircle className="w-12 h-12 text-amber-600" />
)}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-center mb-6"
>
{isSuccess ? (
<>
<h2 className="text-gray-900 mb-2">Request Submitted Successfully</h2>
<p className="text-gray-700 font-medium text-teal-800">
Details have matched and credit note is generated.
</p>
<p className="text-gray-600 text-sm mt-1">
Your Form 16A data matched with 26AS records. Credit note has been generated.
</p>
</>
) : isDuplicate ? (
<>
<h2 className="text-gray-900 mb-2 text-red-700 font-semibold">Duplicate Submission</h2>
<p className="text-gray-900 font-medium">
This Form 16A has already been submitted for the same quarter and financial year.
</p>
<p className="text-gray-700 text-sm mt-1">
A credit note may already have been issued. Please check your Closed Requests or Credit Notes.
</p>
{message && (
<div className="mt-3 p-3 bg-red-50 border-2 border-red-200 rounded-md">
<p className="text-sm font-semibold text-gray-900 mb-1">Duplicate submission not allowed</p>
<p className="text-sm text-gray-900 whitespace-pre-wrap">{message}</p>
</div>
)}
</>
) : isMismatch ? (
<>
<h2 className="text-gray-900 mb-2">Value Mismatch</h2>
<p className="text-gray-700 font-medium text-amber-800">
Resubmit the form carefully.
</p>
<p className="text-gray-600 text-sm mt-1">
Form 16A details did not match with 26AS data. Please verify the certificate and
resubmit with correct details.
</p>
{message && (
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-md">
<p className="text-sm font-semibold text-amber-900 mb-1">Validation Error:</p>
<p className="text-sm text-amber-800 whitespace-pre-wrap">{message}</p>
</div>
)}
</>
) : (
<>
<h2 className="text-gray-900 mb-2">Request Submitted</h2>
<p className="text-gray-700 font-medium text-gray-800">
Your request has been received and is being processed.
</p>
<p className="text-gray-600 text-sm mt-1">
If there was an issue, you can try again or contact support.
</p>
{message && (
<p className="text-sm text-gray-600 mt-2 italic">{message}</p>
)}
</>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className={`rounded-lg p-4 mb-6 text-center ${
isSuccess ? 'bg-teal-50 border border-teal-200' : isDuplicate ? 'bg-red-50 border-2 border-red-200' : 'bg-white border border-amber-200'
}`}
>
<p className={`text-sm mb-1 ${isDuplicate ? 'text-red-700' : 'text-gray-600'}`}>Request ID</p>
<p className={`font-mono tracking-wide ${isDuplicate ? 'text-red-800 font-semibold' : 'text-gray-900'}`}>{requestId || '—'}</p>
{isSuccess && creditNoteNumber && (
<>
<p className="text-sm text-gray-600 mt-3 mb-1">Credit Note Number</p>
<p className="text-gray-900 font-mono font-medium text-teal-700">
{creditNoteNumber}
</p>
</>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="mb-8"
>
<p className="text-sm text-gray-600 mb-4 text-center">Process flow</p>
<div className="flex items-center justify-center gap-0">
{steps.map((step, index) => (
<TimelineStep
key={index}
step={index + 1}
label={step.label}
state={step.state}
isLast={index === steps.length - 1}
/>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="flex flex-col sm:flex-row items-center justify-center gap-3"
>
{isSuccess ? (
<>
<p className="text-sm text-gray-500 mb-2 sm:mb-0 sm:mr-2">
Redirecting to My Requests in a moment...
</p>
<Button onClick={onComplete} variant="outline" className="border-teal-300 text-teal-700">
Back to My Requests
</Button>
</>
) : (
<>
{isError && onViewRequest && (
<Button onClick={onViewRequest} className="bg-teal-600 hover:bg-teal-700">
View Request
</Button>
)}
{onResubmit && (isMismatch || isDuplicate || isError) && (
<Button
onClick={onResubmit}
className={
isDuplicate
? 'bg-red-600 hover:bg-red-700'
: isMismatch
? 'bg-amber-600 hover:bg-amber-700'
: 'bg-gray-600 hover:bg-gray-700'
}
>
{isDuplicate ? 'Back to New Submission' : isMismatch ? 'Resubmit Form 16A' : 'Try Again'}
</Button>
)}
<Button onClick={onComplete} variant="outline">
Back to My Requests
</Button>
</>
)}
</motion.div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@ -1,28 +0,0 @@
import { CheckCircle2, XCircle, Clock } from 'lucide-react';
export type StatusChipVariant = 'success' | 'failed' | 'pending';
interface StatusChipProps {
variant: StatusChipVariant;
label: string;
showIcon?: boolean;
}
const variantStyles: Record<StatusChipVariant, string> = {
success: 'bg-teal-100 text-teal-800 border-teal-200',
failed: 'bg-red-100 text-red-800 border-red-200',
pending: 'bg-gray-100 text-gray-700 border-gray-200',
};
export function StatusChip({ variant, label, showIcon = true }: StatusChipProps) {
const Icon =
variant === 'success' ? CheckCircle2 : variant === 'failed' ? XCircle : Clock;
return (
<span
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium ${variantStyles[variant]}`}
>
{showIcon && <Icon className="h-4 w-4 shrink-0" />}
{label}
</span>
);
}

View File

@ -1,38 +0,0 @@
import { CheckCircle2, XCircle, Circle } from 'lucide-react';
export type TimelineStepState = 'completed' | 'failed' | 'pending';
interface TimelineStepProps {
step: number;
label: string;
state: TimelineStepState;
isLast: boolean;
}
export function TimelineStep({ step, label, state, isLast }: TimelineStepProps) {
const Icon = state === 'completed' ? CheckCircle2 : state === 'failed' ? XCircle : Circle;
const iconClass =
state === 'completed'
? 'text-teal-600 bg-teal-100'
: state === 'failed'
? 'text-red-600 bg-red-100'
: 'text-gray-400 bg-gray-100';
return (
<>
<div className="flex flex-col items-center flex-1 min-w-0">
<div className={`flex items-center justify-center w-8 h-8 rounded-full shrink-0 ${iconClass}`}>
{state === 'pending' ? (
<span className="text-xs font-medium text-gray-500">{step}</span>
) : (
<Icon className="w-4 h-4" />
)}
</div>
<p className="text-xs font-medium text-gray-600 mt-2 text-center max-w-[80px] truncate" title={label}>
{label}
</p>
</div>
{!isLast && <div className="w-8 sm:w-12 h-0.5 bg-gray-200 self-start mt-4 shrink-0" aria-hidden />}
</>
);
}

View File

@ -1,128 +0,0 @@
import { Upload, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { useState } from 'react';
export type UploadState = 'idle' | 'dragging' | 'validating' | 'extracting' | 'success' | 'error';
interface UploadCardProps {
state?: UploadState;
onFileSelect: (file: File) => void;
errorMessage?: string;
className?: string;
disabled?: boolean;
}
export function UploadCard({
state = 'idle',
onFileSelect,
errorMessage,
className = '',
disabled = false,
}: UploadCardProps) {
const [isDragging, setIsDragging] = useState(false);
const currentState = isDragging && !disabled ? 'dragging' : state;
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (disabled) return;
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const files = e.dataTransfer.files;
const file = files[0];
if (file) onFileSelect(file);
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
const file = files?.[0];
if (file) onFileSelect(file);
};
const stateConfig: Record<string, { icon: typeof Upload; text: string; subtext: string; bgColor: string; borderColor: string; iconColor: string }> = {
idle: {
icon: Upload,
text: 'Drop PDF here or click to browse',
subtext: 'Only .pdf | Form 16A certificate',
bgColor: 'bg-white hover:bg-gray-50',
borderColor: 'border-gray-300 border-dashed',
iconColor: 'text-gray-400',
},
dragging: {
icon: Upload,
text: 'Release to upload',
subtext: 'Drop your Form 16A PDF here',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-400 border-dashed',
iconColor: 'text-blue-500',
},
validating: {
icon: Loader2,
text: 'Validating Form 16A...',
subtext: 'Please wait',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-300',
iconColor: 'text-blue-600 animate-spin',
},
extracting: {
icon: Loader2,
text: 'Extracting data from Form 16A...',
subtext: 'Using Google Gemini (regex fallback if needed)',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-300',
iconColor: 'text-blue-600 animate-spin',
},
success: {
icon: CheckCircle2,
text: 'Form 16A uploaded successfully',
subtext: 'Document ready for submission',
bgColor: 'bg-green-50',
borderColor: 'border-green-300',
iconColor: 'text-green-600',
},
error: {
icon: AlertCircle,
text: 'Upload failed',
subtext: errorMessage || 'Please try again',
bgColor: 'bg-red-50',
borderColor: 'border-red-300',
iconColor: 'text-red-600',
},
};
const config = stateConfig[currentState] ?? stateConfig.idle;
const Icon = config!.icon;
return (
<div className={className}>
<label
className={`flex flex-col items-center justify-center w-full min-h-[200px] border-2 rounded-lg transition-all ${config?.bgColor ?? ''} ${config?.borderColor ?? ''} ${!disabled && state === 'idle' ? 'cursor-pointer' : 'cursor-default'}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="flex flex-col items-center justify-center py-8 px-4">
<Icon className={`w-12 h-12 mb-4 ${config?.iconColor ?? ''}`} />
<p className="mb-2 text-gray-700 font-medium">{config?.text ?? ''}</p>
<p className="text-sm text-gray-500">{config?.subtext ?? ''}</p>
</div>
{currentState === 'idle' && !disabled && (
<input
type="file"
className="hidden"
accept=".pdf,application/pdf"
onChange={handleFileInput}
/>
)}
</label>
</div>
);
}

View File

@ -103,10 +103,11 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<PriorityIcon className="w-3 h-3 mr-1" /> <PriorityIcon className="w-3 h-3 mr-1" />
{request.priority} {request.priority}
</Badge> </Badge>
{/* Template Type Badge Form 16 shows green "Form 16" */} {/* Template Type Badge */}
{(() => { {(() => {
const templateType = request?.templateType || (request as any)?.template_type || ''; const templateType = request?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType
let templateLabel = 'Non-Templatized'; let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200'; let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
@ -114,9 +115,6 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
if (templateTypeUpper === 'DEALER CLAIM') { if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim'; templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200'; templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'FORM_16') {
templateLabel = 'Form 16';
templateColor = 'bg-emerald-100 !text-emerald-700 border-emerald-200';
} else if (templateTypeUpper === 'TEMPLATE') { } else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template'; templateLabel = 'Template';
} }
@ -148,34 +146,23 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 mt-1" /> <ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 mt-1" />
</div> </div>
{/* Current Approver and Level Info Form 16 shows "Form 16 OCR FLOW" */} {/* Current Approver and Level Info */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 pt-3 border-t border-gray-100"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 pt-3 border-t border-gray-100">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
{(request?.templateType || (request as any)?.template_type || '').toString().toUpperCase() === 'FORM_16' ? ( <div className="flex items-center gap-2 min-w-0">
<div className="flex items-center gap-2 min-w-0"> <User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-emerald-600 flex-shrink-0" /> <span className="text-xs sm:text-sm truncate" data-testid="current-approver">
<span className="text-xs sm:text-sm font-medium text-emerald-700" data-testid="form16-ocr-flow"> <span className="text-gray-500">Current Approver:</span>{' '}
Form 16 OCR FLOW <span className="text-gray-900 font-medium">{request.currentApprover}</span>
</span> </span>
</div> </div>
) : ( <div className="flex items-center gap-2">
<> <TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
<div className="flex items-center gap-2 min-w-0"> <span className="text-xs sm:text-sm" data-testid="approval-level">
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" /> <span className="text-gray-500">Approval Level:</span>{' '}
<span className="text-xs sm:text-sm truncate" data-testid="current-approver"> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
<span className="text-gray-500">Current Approver:</span>{' '} </span>
<span className="text-gray-900 font-medium">{request.currentApprover}</span> </div>
</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
<span className="text-xs sm:text-sm" data-testid="approval-level">
<span className="text-gray-500">Approval Level:</span>{' '}
<span className="text-gray-900 font-medium">{request.approverLevel}</span>
</span>
</div>
</>
)}
</div> </div>
<div className="flex items-center gap-1.5 text-xs text-gray-500"> <div className="flex items-center gap-1.5 text-xs text-gray-500">
<Clock className="w-3.5 h-3.5 flex-shrink-0" /> <Clock className="w-3.5 h-3.5 flex-shrink-0" />

View File

@ -30,18 +30,6 @@ interface Request {
isPaused?: boolean; // Pause status isPaused?: boolean; // Pause status
pauseInfo?: any; // Pause details pauseInfo?: any; // Pause details
templateType?: string; // Template type for badge display templateType?: string; // Template type for badge display
/** Form 16: dealer code, form16a number, FY, quarter, totalAmount, creditNoteNumber, displayStatus */
form16Submission?: {
dealerCode?: string;
form16aNumber?: string;
financialYear?: string;
quarter?: string;
status?: string;
submittedDate?: string;
totalAmount?: number | null;
creditNoteNumber?: string | null;
displayStatus?: string;
} | null;
} }
interface OpenRequestsProps { interface OpenRequestsProps {
@ -151,19 +139,15 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
// Since we know user type initially, this helper uses that knowledge // Since we know user type initially, this helper uses that knowledge
// Note: This doesn't need useCallback since we'll use it inline in effects to avoid dependency issues // Note: This doesn't need useCallback since we'll use it inline in effects to avoid dependency issues
const getFilterParams = (includeStatus?: boolean) => { const getFilterParams = (includeStatus?: boolean) => {
const params: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string } = { return {
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
// Only include status, priority, and templateType filters if user is not a dealer
status: includeStatus && !isDealer && filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: includeStatus && !isDealer && filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder sortOrder: filters.sortOrder
}; };
if (params.templateType === 'FORM_16') {
if (filters.form16FinancialYear) params.financialYear = filters.form16FinancialYear;
if (filters.form16Quarter) params.quarter = filters.form16Quarter;
}
return params;
}; };
// Fetch open requests for the current user only (user-scoped, not organization-wide) // Fetch open requests for the current user only (user-scoped, not organization-wide)
@ -173,25 +157,23 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
// - An initiator (for approved requests awaiting closure) // - An initiator (for approved requests awaiting closure)
// This applies to ALL users including regular users, MANAGEMENT, and ADMIN roles // This applies to ALL users including regular users, MANAGEMENT, and ADMIN roles
// For organization-wide view, users should use the "All Requests" screen (/requests) // For organization-wide view, users should use the "All Requests" screen (/requests)
const fetchRequests = useCallback(async (page: number = 1, filterParams?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string }) => { const fetchRequests = useCallback(async (page: number = 1, filterParams?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
setLoading(true); setLoading(true);
setItems([]); setItems([]);
} }
// Always use user-scoped endpoint (not organization-wide) // Always use user-scoped endpoint (not organization-wide)
// Backend filters by userId regardless of user role (ADMIN/MANAGEMENT/regular user) // Backend filters by userId regardless of user role (ADMIN/MANAGEMENT/regular user)
// For organization-wide requests, use the "All Requests" screen (/requests) // For organization-wide requests, use the "All Requests" screen (/requests)
const result = await workflowApi.listOpenForMe({ const result = await workflowApi.listOpenForMe({
page, page,
limit: itemsPerPage, limit: itemsPerPage,
search: filterParams?.search, search: filterParams?.search,
status: filterParams?.status, status: filterParams?.status,
priority: filterParams?.priority, priority: filterParams?.priority,
templateType: filterParams?.templateType, templateType: filterParams?.templateType,
financialYear: filterParams?.financialYear,
quarter: filterParams?.quarter,
sortBy: filterParams?.sortBy, sortBy: filterParams?.sortBy,
sortOrder: filterParams?.sortOrder sortOrder: filterParams?.sortOrder
}); });
@ -234,7 +216,6 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
department: r.department, department: r.department,
currentLevelSLA: r.currentLevelSLA, // ← Backend-calculated current level SLA currentLevelSLA: r.currentLevelSLA, // ← Backend-calculated current level SLA
templateType: r.templateType || r.template_type, // ← Template type for badge display templateType: r.templateType || r.template_type, // ← Template type for badge display
form16Submission: r.form16Submission ?? null, // ← Form 16 columns for All Requests
}; };
}); });
setItems(mapped); setItems(mapped);
@ -337,7 +318,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.form16FinancialYear, filters.form16Quarter, filters.sortBy, filters.sortOrder, isDealer]); }, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
// Backend handles both filtering and sorting - use items directly // Backend handles both filtering and sorting - use items directly
// No client-side sorting needed anymore // No client-side sorting needed anymore
@ -384,16 +365,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
statusFilter={filters.statusFilter} statusFilter={filters.statusFilter}
priorityFilter={filters.priorityFilter} priorityFilter={filters.priorityFilter}
templateTypeFilter={filters.templateTypeFilter} templateTypeFilter={filters.templateTypeFilter}
form16FinancialYear={filters.form16FinancialYear}
form16Quarter={filters.form16Quarter}
sortBy={filters.sortBy} sortBy={filters.sortBy}
sortOrder={filters.sortOrder} sortOrder={filters.sortOrder}
onSearchChange={filters.setSearchTerm} onSearchChange={filters.setSearchTerm}
onStatusFilterChange={filters.setStatusFilter} onStatusFilterChange={filters.setStatusFilter}
onPriorityFilterChange={filters.setPriorityFilter} onPriorityFilterChange={filters.setPriorityFilter}
onTemplateTypeFilterChange={filters.setTemplateTypeFilter} onTemplateTypeFilterChange={filters.setTemplateTypeFilter}
onForm16FinancialYearChange={filters.setForm16FinancialYear}
onForm16QuarterChange={filters.setForm16Quarter}
onSortByChange={filters.setSortBy} onSortByChange={filters.setSortBy}
onSortOrderChange={filters.setSortOrder} onSortOrderChange={filters.setSortOrder}
onClearFilters={filters.clearFilters} onClearFilters={filters.clearFilters}
@ -404,22 +381,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
<div className="space-y-3"> <div className="space-y-3">
{filteredAndSortedRequests.map((request) => { {filteredAndSortedRequests.map((request) => {
const priorityConfig = getPriorityConfig(request.priority); const priorityConfig = getPriorityConfig(request.priority);
const isForm16 = ((request as any).templateType || (request as any).template_type || '').toString().toUpperCase() === 'FORM_16'; const statusConfig = getStatusConfig(request.status);
const form16Sub = (request as any).form16Submission;
const form16DisplayStatus = form16Sub?.displayStatus;
const isForm16MismatchOrFailed = form16DisplayStatus && /balance mismatch|failed/i.test(String(form16DisplayStatus));
const statusConfig = isForm16 && form16DisplayStatus
? {
color: isForm16MismatchOrFailed
? 'bg-red-100 text-red-800 border-red-200'
: form16DisplayStatus === 'Completed'
? 'bg-green-100 text-green-800 border-green-200'
: 'bg-gray-100 text-gray-700 border-gray-200',
icon: AlertCircle,
iconColor: isForm16MismatchOrFailed ? 'text-red-600' : 'text-gray-600',
label: form16DisplayStatus,
}
: getStatusConfig(request.status);
return ( return (
<Card <Card
@ -473,9 +435,6 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
if (templateTypeUpper === 'DEALER CLAIM') { if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim'; templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200'; templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'FORM_16') {
templateLabel = 'Form 16';
templateColor = 'bg-emerald-100 !text-emerald-700 border-emerald-200';
} else if (templateTypeUpper === 'TEMPLATE') { } else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template'; templateLabel = 'Template';
} }
@ -497,27 +456,6 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
{request.title} {request.title}
</h4> </h4>
{/* Form 16: dealer, form16a, FY, quarter, total amount, credit note no., status (displayStatus, never Pending) */}
{((request as any).templateType || '').toString().toUpperCase() === 'FORM_16' && (request as any).form16Submission && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-600 mt-1">
{(request as any).form16Submission.dealerCode && (
<span><span className="text-gray-500">Dealer:</span> {(request as any).form16Submission.dealerCode}</span>
)}
{(request as any).form16Submission.form16aNumber && (
<span><span className="text-gray-500">Form 16A:</span> {(request as any).form16Submission.form16aNumber}</span>
)}
{(request as any).form16Submission.financialYear && (
<span><span className="text-gray-500">FY:</span> {(request as any).form16Submission.financialYear}</span>
)}
{(request as any).form16Submission.quarter && (
<span><span className="text-gray-500">Q:</span> {(request as any).form16Submission.quarter}</span>
)}
<span><span className="text-gray-500">Total amount:</span> {(request as any).form16Submission.totalAmount != null ? new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format((request as any).form16Submission.totalAmount) : '—'}</span>
<span><span className="text-gray-500">Credit note:</span> {(request as any).form16Submission.creditNoteNumber || '—'}</span>
<span><span className="text-gray-500">Status:</span> {(request as any).form16Submission.displayStatus || (request as any).form16Submission.status || '—'}</span>
</div>
)}
{/* SLA Display - Compact Version */} {/* SLA Display - Compact Version */}
{request.currentLevelSLA && (() => { {request.currentLevelSLA && (() => {
// Check pause status from isPaused field, pauseInfo, OR status field // Check pause status from isPaused field, pauseInfo, OR status field

View File

@ -9,8 +9,6 @@ import {
setStatusFilter as setStatusFilterAction, setStatusFilter as setStatusFilterAction,
setPriorityFilter as setPriorityFilterAction, setPriorityFilter as setPriorityFilterAction,
setTemplateTypeFilter as setTemplateTypeFilterAction, setTemplateTypeFilter as setTemplateTypeFilterAction,
setForm16FinancialYear as setForm16FinancialYearAction,
setForm16Quarter as setForm16QuarterAction,
setSortBy as setSortByAction, setSortBy as setSortByAction,
setSortOrder as setSortOrderAction, setSortOrder as setSortOrderAction,
setCurrentPage as setCurrentPageAction, setCurrentPage as setCurrentPageAction,
@ -20,14 +18,14 @@ import {
export function useOpenRequestsFilters() { export function useOpenRequestsFilters() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, form16FinancialYear, form16Quarter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.openRequests); // Get filters from Redux
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.openRequests);
// Create setters that dispatch Redux actions
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]); const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]); const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]); const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]); const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
const setForm16FinancialYear = useCallback((value: string) => dispatch(setForm16FinancialYearAction(value)), [dispatch]);
const setForm16Quarter = useCallback((value: string) => dispatch(setForm16QuarterAction(value)), [dispatch]);
const setSortBy = useCallback((value: 'created' | 'due' | 'priority' | 'sla') => dispatch(setSortByAction(value)), [dispatch]); const setSortBy = useCallback((value: 'created' | 'due' | 'priority' | 'sla') => dispatch(setSortByAction(value)), [dispatch]);
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]); const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]); const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
@ -37,9 +35,7 @@ export function useOpenRequestsFilters() {
searchTerm, searchTerm,
priorityFilter !== 'all' ? priorityFilter : null, priorityFilter !== 'all' ? priorityFilter : null,
statusFilter !== 'all' ? statusFilter : null, statusFilter !== 'all' ? statusFilter : null,
templateTypeFilter !== 'all' ? templateTypeFilter : null, templateTypeFilter !== 'all' ? templateTypeFilter : null
templateTypeFilter === 'FORM_16' && form16FinancialYear ? 'fy' : null,
templateTypeFilter === 'FORM_16' && form16Quarter ? 'q' : null,
].filter(Boolean).length; ].filter(Boolean).length;
return { return {
@ -47,8 +43,6 @@ export function useOpenRequestsFilters() {
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter, templateTypeFilter,
form16FinancialYear,
form16Quarter,
sortBy, sortBy,
sortOrder, sortOrder,
currentPage, currentPage,
@ -56,8 +50,6 @@ export function useOpenRequestsFilters() {
setStatusFilter, setStatusFilter,
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter, setTemplateTypeFilter,
setForm16FinancialYear,
setForm16Quarter,
setSortBy, setSortBy,
setSortOrder, setSortOrder,
setCurrentPage, setCurrentPage,

View File

@ -5,8 +5,6 @@ export interface OpenRequestsFiltersState {
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
form16FinancialYear: string;
form16Quarter: string;
sortBy: 'created' | 'due' | 'priority' | 'sla'; sortBy: 'created' | 'due' | 'priority' | 'sla';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
currentPage: number; currentPage: number;
@ -17,8 +15,6 @@ const initialState: OpenRequestsFiltersState = {
statusFilter: 'all', statusFilter: 'all',
priorityFilter: 'all', priorityFilter: 'all',
templateTypeFilter: 'all', templateTypeFilter: 'all',
form16FinancialYear: '',
form16Quarter: '',
sortBy: 'created', sortBy: 'created',
sortOrder: 'desc', sortOrder: 'desc',
currentPage: 1, currentPage: 1,
@ -40,12 +36,6 @@ const openRequestsSlice = createSlice({
setTemplateTypeFilter: (state, action: PayloadAction<string>) => { setTemplateTypeFilter: (state, action: PayloadAction<string>) => {
state.templateTypeFilter = action.payload; state.templateTypeFilter = action.payload;
}, },
setForm16FinancialYear: (state, action: PayloadAction<string>) => {
state.form16FinancialYear = action.payload;
},
setForm16Quarter: (state, action: PayloadAction<string>) => {
state.form16Quarter = action.payload;
},
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority' | 'sla'>) => { setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority' | 'sla'>) => {
state.sortBy = action.payload; state.sortBy = action.payload;
}, },
@ -60,8 +50,6 @@ const openRequestsSlice = createSlice({
state.statusFilter = 'all'; state.statusFilter = 'all';
state.priorityFilter = 'all'; state.priorityFilter = 'all';
state.templateTypeFilter = 'all'; state.templateTypeFilter = 'all';
state.form16FinancialYear = '';
state.form16Quarter = '';
state.currentPage = 1; state.currentPage = 1;
}, },
}, },
@ -72,8 +60,6 @@ export const {
setStatusFilter, setStatusFilter,
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter, setTemplateTypeFilter,
setForm16FinancialYear,
setForm16Quarter,
setSortBy, setSortBy,
setSortOrder, setSortOrder,
setCurrentPage, setCurrentPage,

View File

@ -31,18 +31,7 @@ export function RequestDetailHeader({
isPaused = false // Module passes pause status isPaused = false // Module passes pause status
}: RequestDetailHeaderProps) { }: RequestDetailHeaderProps) {
const priorityConfig = getPriorityConfig(request?.priority || 'standard'); const priorityConfig = getPriorityConfig(request?.priority || 'standard');
const isForm16 = (request?.templateType || request?.template_type || '').toString().toUpperCase() === 'FORM_16'; const statusConfig = getStatusConfig(request?.status || 'pending');
const form16DisplayStatus = request?.form16Submission?.displayStatus;
const isForm16MismatchOrFailed = form16DisplayStatus && /balance mismatch|failed/i.test(String(form16DisplayStatus));
const isForm16Duplicate = form16DisplayStatus && String(form16DisplayStatus).toLowerCase() === 'duplicate';
const statusConfig = isForm16 && form16DisplayStatus
? {
color: isForm16MismatchOrFailed ? 'bg-red-100 !text-red-800 border-red-200' : form16DisplayStatus === 'Completed' ? 'bg-green-100 !text-green-800 border-green-200' : isForm16Duplicate ? 'bg-amber-100 !text-amber-800 border-amber-200' : 'bg-gray-100 !text-gray-700 border-gray-200',
icon: getStatusConfig(request?.status || 'pending').icon,
iconColor: isForm16MismatchOrFailed ? '!text-red-600' : isForm16Duplicate ? '!text-amber-600' : '!text-gray-600',
label: form16DisplayStatus,
}
: getStatusConfig(request?.status || 'pending');
return ( return (
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header"> <div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
@ -88,33 +77,27 @@ export function RequestDetailHeader({
> >
{statusConfig.label} {statusConfig.label}
</Badge> </Badge>
{/* Template Type Badge Form 16 shows green "Form 16", others unchanged */} {/* Template Type Badge */}
{(() => { {(() => {
const workflowType = request?.workflowType || request?.workflow_type; const workflowType = request?.workflowType || request?.workflow_type;
const templateTypeRaw = request?.templateType || request?.template_type || ''; const templateType = request?.templateType || request?.template_type || '';
const templateTypeUpper = templateTypeRaw?.toString().toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
const isForm16 = templateTypeUpper === 'FORM_16'; // Check for dealer claim - support multiple formats
const isClaimManagement = const isDealerClaim =
workflowType === 'CLAIM_MANAGEMENT' || workflowType === 'CLAIM_MANAGEMENT' ||
templateTypeRaw === 'claim-management';
const isDealerClaim =
workflowType === 'DEALER_CLAIM' || workflowType === 'DEALER_CLAIM' ||
templateType === 'claim-management' ||
templateTypeUpper === 'DEALER CLAIM' || templateTypeUpper === 'DEALER CLAIM' ||
templateTypeUpper === 'DEALER_CLAIM'; templateTypeUpper === 'DEALER_CLAIM';
// Direct mapping from templateType
let templateLabel = 'Non-Templatized'; let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200'; let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (isForm16) { if (isDealerClaim) {
templateLabel = 'Form 16';
templateColor = 'bg-emerald-100 !text-emerald-700 border-emerald-200';
} else if (isDealerClaim) {
templateLabel = 'Dealer Claim'; templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200'; templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (isClaimManagement) {
templateLabel = 'Claim Management';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') { } else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template'; templateLabel = 'Template';
} }

View File

@ -1,6 +1,5 @@
/** /**
* Activity Tab Component * Activity Tab Component
* For Form 16 requests, prepends a structured timeline: Form 16A upload OCR extraction 26AS matching result (e.g. mismatch).
*/ */
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@ -12,98 +11,7 @@ interface ActivityTabProps {
request: any; request: any;
} }
/** Build Form 16specific timeline entries: previous submissions first (full journey), then current submission */
function getForm16TimelineEntries(request: any): Array<{ type: string; action: string; details: string; timestamp: string }> {
const form16 = request?.form16Submission;
if (!form16) return [];
const entries: Array<{ type: string; action: string; details: string; timestamp: string }> = [];
const previousSubmissions = Array.isArray(form16.previousSubmissions) ? form16.previousSubmissions : [];
// Previous submissions for same FY & quarter (chronological: submitted → credit note)
for (const prev of previousSubmissions) {
const reqNum = prev.requestNumber || prev.requestId || '';
const submittedTs = prev.submittedDate ? new Date(prev.submittedDate).toISOString() : new Date().toISOString();
entries.push({
type: 'document_added',
action: `Previous submission (${reqNum})`,
details: 'Form 16A certificate was submitted for this FY and quarter.',
timestamp: submittedTs,
});
if (prev.creditNoteNumber) {
const cnTs = prev.creditNoteIssueDate ? new Date(prev.creditNoteIssueDate).toISOString() : submittedTs;
entries.push({
type: 'status_change',
action: 'Credit note issued',
details: `Credit note ${prev.creditNoteNumber} issued for previous submission (${reqNum}).`,
timestamp: cnTs,
});
}
}
// Current submission: upload → OCR → 26AS result
const submittedAt = form16.submittedDate || request?.submittedDate || request?.createdAt;
const submittedTs = submittedAt ? new Date(submittedAt).toISOString() : new Date().toISOString();
const validationStatus = (form16.validationStatus || '').toLowerCase();
const validationNotes = (form16.validationNotes || '') || '';
const displayStatus = (form16.displayStatus || '').toLowerCase();
const hasOcr = !!(form16.ocrExtractedData && typeof form16.ocrExtractedData === 'object' && Object.keys(form16.ocrExtractedData).length > 0);
const hasCreditNote = !!(form16.creditNoteNumber);
const isMismatch = displayStatus === 'balance mismatch' || (validationStatus === 'failed' && !hasCreditNote) || (validationStatus === 'failed' && /mismatch|26as|value/i.test(validationNotes));
const isDuplicate = displayStatus === 'duplicate' || validationStatus === 'duplicate';
entries.push({
type: 'document_added',
action: 'Form 16A uploaded',
details: 'Form 16A certificate was uploaded and received.',
timestamp: submittedTs,
});
entries.push({
type: 'created',
action: 'OCR extraction',
details: hasOcr ? 'Certificate data was extracted from the uploaded PDF.' : 'OCR extraction was performed on the uploaded document.',
timestamp: submittedTs,
});
if (isMismatch) {
entries.push({
type: 'rejection',
action: '26AS matching',
details: 'Values of Form 16 did not match with 26AS. Please submit Form 16 with correct data.',
timestamp: submittedTs,
});
} else if (isDuplicate) {
entries.push({
type: 'rejection',
action: '26AS matching',
details: 'Duplicate. A submission for this FY and quarter already exists; credit note was issued for the earlier submission.',
timestamp: submittedTs,
});
} else {
entries.push({
type: 'status_change',
action: '26AS matching',
details: validationStatus === 'success' || form16.creditNoteNumber
? '26AS matching completed. Credit note generated.'
: '26AS matching was performed.',
timestamp: submittedTs,
});
}
return entries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
}
export function ActivityTab({ request }: ActivityTabProps) { export function ActivityTab({ request }: ActivityTabProps) {
const isForm16 = (request?.templateType || request?.template_type || '').toString().toUpperCase() === 'FORM_16';
const form16Entries = isForm16 ? getForm16TimelineEntries(request) : [];
const auditTrail = request.auditTrail && Array.isArray(request.auditTrail) ? request.auditTrail : [];
const allEntries = form16Entries.length > 0 ? [...form16Entries, ...auditTrail].sort((a: any, b: any) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return ta - tb;
}) : auditTrail;
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -111,29 +19,27 @@ export function ActivityTab({ request }: ActivityTabProps) {
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600" /> <Activity className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600" />
Activity Timeline Activity Timeline
</CardTitle> </CardTitle>
<CardDescription className="text-xs sm:text-sm"> <CardDescription className="text-xs sm:text-sm">Complete audit trail of all request activities</CardDescription>
{isForm16 ? 'Form 16 submission steps and audit trail' : 'Complete audit trail of all request activities'}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4 sm:space-y-6">
{allEntries.length > 0 ? ( {request.auditTrail && request.auditTrail.length > 0 ? (
allEntries.map((entry: any, index: number) => ( request.auditTrail.map((entry: any, index: number) => (
<div key={index} className="flex items-start gap-4" data-testid={`activity-item-${index}`}> <div key={index} className="flex items-start gap-4" data-testid={`activity-item-${index}`}>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${entry.type === 'rejection' ? 'bg-red-50' : 'bg-gray-100'}`}> <div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
{getActionTypeIcon(entry.type)} {getActionTypeIcon(entry.type)}
</div> </div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={`rounded-lg border p-4 shadow-sm ${entry.type === 'rejection' ? 'bg-red-50/50 border-red-200' : 'bg-white border-gray-200'}`}> <div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h4 className={`font-semibold ${entry.type === 'rejection' ? 'text-red-900' : 'text-gray-900'}`}>{entry.action}</h4> <h4 className="font-semibold text-gray-900">{entry.action}</h4>
<span className="text-xs text-gray-500 whitespace-nowrap ml-4">{formatDateTime(entry.timestamp)}</span> <span className="text-xs text-gray-500 whitespace-nowrap ml-4">{formatDateTime(entry.timestamp)}</span>
</div> </div>
<div className={`text-sm leading-relaxed ${entry.type === 'rejection' ? 'text-red-800' : 'text-gray-600'}`}> <div className="text-sm text-gray-600 leading-relaxed">
<p className="whitespace-pre-line break-words">{entry.details}</p> <p className="whitespace-pre-line break-words">{entry.details}</p>
</div> </div>
</div> </div>

View File

@ -26,79 +26,19 @@ export function DocumentsTab({
setPreviewDocument, setPreviewDocument,
downloadDocument, downloadDocument,
}: DocumentsTabProps) { }: DocumentsTabProps) {
const isForm16 = (request?.templateType || request?.template_type || '').toString().toUpperCase() === 'FORM_16';
return ( return (
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4 sm:space-y-6">
{/* Form 16: Previous submission(s) documents — only same FY & quarter as this request */} {/* Request Documents */}
{isForm16 && request?.form16Submission?.previousDocuments?.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm sm:text-base text-amber-800">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600" />
Previous submission(s) same quarter
</CardTitle>
<CardDescription className="text-xs sm:text-sm mt-1">
Documents from earlier Form 16A submissions for this request&apos;s financial year and quarter only ({request?.form16Submission?.financialYear} {request?.form16Submission?.quarter})
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{(() => {
const byRequest = new Map<string, any[]>();
for (const d of request.form16Submission.previousDocuments) {
const reqId = d.requestId || d.request_id;
const reqNum = d.requestNumber || reqId;
if (!byRequest.has(reqNum)) byRequest.set(reqNum, []);
byRequest.get(reqNum)!.push(d);
}
return Array.from(byRequest.entries()).map(([reqNum, docs]) => (
<div key={reqNum} className="space-y-2">
<p className="text-xs font-medium text-gray-600">Request {reqNum}</p>
<div className="space-y-2 pl-2 border-l-2 border-amber-200">
{docs.map((d: any, idx: number) => {
const docId = d.documentId ?? d.document_id;
const name = d.originalFileName ?? d.original_file_name ?? d.fileName ?? d.file_name ?? 'Document';
const sizeBytes = Number(d.fileSize ?? d.file_size ?? 0);
const sizeMb = sizeBytes > 0 ? (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB' : '—';
return (
<DocumentCard
key={docId || idx}
document={{
documentId: docId,
name,
fileType: d.fileType ?? d.file_type ?? '',
size: sizeMb,
sizeBytes,
uploadedBy: d.uploadedBy ?? d.uploaded_by,
uploadedAt: d.uploadedAt ?? d.uploaded_at,
}}
onPreview={(previewDoc) => setPreviewDocument(previewDoc)}
onDownload={downloadDocument}
testId="form16-previous-document"
/>
);
})}
</div>
</div>
));
})()}
</div>
</CardContent>
</Card>
)}
{/* Request Documents (current submission) */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div> <div>
<CardTitle className="flex items-center gap-2 text-sm sm:text-base"> <CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" /> <FileText className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
{isForm16 ? 'Current submission' : 'Request Documents'} Request Documents
</CardTitle> </CardTitle>
<CardDescription className="text-xs sm:text-sm mt-1"> <CardDescription className="text-xs sm:text-sm mt-1">
{isForm16 ? 'Documents for this Form 16A submission' : 'Documents attached while creating the request'} Documents attached while creating the request
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
@ -138,48 +78,46 @@ export function DocumentsTab({
</CardContent> </CardContent>
</Card> </Card>
{/* Work Note Attachments hidden for Form 16 (no work notes in Form 16 flow) */} {/* Work Note Attachments */}
{!isForm16 && ( <Card>
<Card> <CardHeader>
<CardHeader> <CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base"> <MessageSquare className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
<MessageSquare className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" /> Work Note Attachments
Work Note Attachments </CardTitle>
</CardTitle> <CardDescription className="text-xs sm:text-sm">Files shared in work notes discussions</CardDescription>
<CardDescription className="text-xs sm:text-sm">Files shared in work notes discussions</CardDescription> </CardHeader>
</CardHeader> <CardContent>
<CardContent> {workNoteAttachments && workNoteAttachments.length > 0 ? (
{workNoteAttachments && workNoteAttachments.length > 0 ? ( <div className="space-y-3">
<div className="space-y-3"> {workNoteAttachments.map((file: any, index: number) => (
{workNoteAttachments.map((file: any, index: number) => ( <DocumentCard
<DocumentCard key={file.attachmentId || index}
key={file.attachmentId || index} document={{
document={{ documentId: file.attachmentId || '',
documentId: file.attachmentId || '', name: file.name,
name: file.name, fileType: file.type || '',
fileType: file.type || '', size: file.size ? `${(file.size / 1024).toFixed(1)} KB` : 'Unknown size',
size: file.size ? `${(file.size / 1024).toFixed(1)} KB` : 'Unknown size', sizeBytes: file.size,
sizeBytes: file.size, uploadedBy: file.uploadedBy,
uploadedBy: file.uploadedBy, uploadedAt: file.uploadedAt,
uploadedAt: file.uploadedAt, }}
}} onPreview={(previewDoc) => setPreviewDocument(previewDoc)}
onPreview={(previewDoc) => setPreviewDocument(previewDoc)} onDownload={async (attachmentId) => {
onDownload={async (attachmentId) => { const { downloadWorkNoteAttachment } = require('@/services/workflowApi');
const { downloadWorkNoteAttachment } = require('@/services/workflowApi'); await downloadWorkNoteAttachment(attachmentId);
await downloadWorkNoteAttachment(attachmentId); }}
}} testId="worknote-attachment"
testId="worknote-attachment" />
/> ))}
))} </div>
</div> ) : (
) : ( <p className="text-sm text-gray-500 text-center py-8" data-testid="no-attachments">
<p className="text-sm text-gray-500 text-center py-8" data-testid="no-attachments"> No files shared in work notes yet
No files shared in work notes yet </p>
</p> )}
)} </CardContent>
</CardContent> </Card>
</Card>
)}
</div> </div>
); );
} }

View File

@ -3,12 +3,11 @@
*/ */
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { RichTextEditor } from '@/components/ui/rich-text-editor'; import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { FormattedDescription } from '@/components/common/FormattedDescription'; import { FormattedDescription } from '@/components/common/FormattedDescription';
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2, Pause, Play, AlertCircle, Scan } from 'lucide-react'; import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2, Pause, Play, AlertCircle } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -71,213 +70,9 @@ export function OverviewTab({
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume) // Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger; const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
const templateType = (request?.templateType || request?.template_type || '').toString().toUpperCase();
const isForm16 = templateType === 'FORM_16' ||
(request?.title || '').toLowerCase().includes('form 16') ||
(request?.description || '').toLowerCase().includes('form 16a');
const form16 = request?.form16Submission;
const rawOcr = isForm16 && form16?.ocrExtractedData && typeof form16.ocrExtractedData === 'object' ? form16.ocrExtractedData as Record<string, unknown> : null;
const ocrData = isForm16 && rawOcr && Object.keys(rawOcr).length > 0
? rawOcr
: isForm16 && form16
? ({
deductorName: form16.deductorName,
deductor_name: form16.deductorName,
tanNumber: form16.tanNumber,
tan_number: form16.tanNumber,
financialYear: form16.financialYear,
financial_year: form16.financialYear,
quarter: form16.quarter,
form16aNumber: form16.form16aNumber,
form16a_number: form16.form16aNumber,
totalAmount: form16.totalAmount,
total_amount: form16.totalAmount,
tdsAmount: form16.tdsAmount,
tds_amount: form16.tdsAmount,
acknowledgementNumber: form16.acknowledgementNumber,
acknowledgement_number: form16.acknowledgementNumber,
dateOfIssue: form16.dateOfIssue,
date_of_issue: form16.dateOfIssue,
deduceeName: form16.deduceeName,
deducee_name: form16.deduceeName,
deduceePan: form16.deduceePan,
deducee_pan: form16.deduceePan,
} as Record<string, unknown>)
: null;
const formatInr = (val: number | string | null | undefined) =>
val != null ? new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(Number(val)) : '';
const displayValue = (val: unknown) => (val == null || val === '') ? '' : String(val);
const getForm16StatusBadgeClass = (s: string | undefined) => {
const x = (s || '').toLowerCase();
if (x === 'completed') return 'bg-emerald-100 text-emerald-800 border-emerald-200';
if (x === 'duplicate' || x === 'duplicate submission') return 'bg-amber-100 text-amber-800 border-amber-200';
if (x === 'balance mismatch' || x === 'failed') return 'bg-red-100 text-red-800 border-red-200';
if (x === 'resubmission needed' || x === 'partial extracted data') return 'bg-amber-50 text-amber-700 border-amber-200';
return 'bg-gray-100 text-gray-700 border-gray-200';
};
const getForm16StatusMessage = (f16: any): string | null => {
const status = (f16?.displayStatus || '').toLowerCase();
const notes = (f16?.validationNotes || '').toLowerCase();
const is26AsMismatch = status === 'balance mismatch' || (status === 'failed' && (notes.includes('mismatch') || notes.includes('26as') || notes.includes('value')));
if (is26AsMismatch) return 'Failed - data mismatch with 26AS, submit the form 16 with correct data';
if (status === 'duplicate' || status === 'duplicate submission') return 'Duplicate. A submission for this FY and quarter already exists.';
if (status === 'resubmission needed') return f16?.validationNotes || 'Resubmission needed. Please submit again with correct data.';
if (status === 'failed') return f16?.validationNotes || 'Submission failed. Please resubmit.';
return null;
};
return ( return (
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content"> <div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
{/* Form 16 submission status */}
{isForm16 && form16?.displayStatus != null && (
<div className="flex flex-col gap-2" data-testid="form16-submission-status">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-gray-700">Submission status:</span>
<Badge variant="outline" className={getForm16StatusBadgeClass(form16.displayStatus)}>{form16.displayStatus}</Badge>
</div>
{getForm16StatusMessage(form16) && (
<p className={`text-sm ${((form16.displayStatus || '').toLowerCase() === 'balance mismatch' || (form16.displayStatus || '').toLowerCase() === 'failed') ? 'text-red-700 font-medium' : 'text-gray-600'}`}>
{getForm16StatusMessage(form16)}
</p>
)}
</div>
)}
{/* Form 16A Certificate Details always show for Form 16 requests; full details when form16Submission exists */}
{isForm16 && (
<Card data-testid="form16-certificate-details" className="border-emerald-200 bg-emerald-50/30">
<CardHeader className="pb-3 sm:pb-4">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base text-emerald-800">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-emerald-600" />
Form 16A Certificate Details
</CardTitle>
<CardDescription className="text-xs text-gray-600">TDS Certificate as per Income Tax Act, 1961</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{form16 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><p className="text-xs text-gray-500">Certificate Number</p><p className="text-sm font-medium">{form16.form16aNumber || ''}</p></div>
<div><p className="text-xs text-gray-500">Financial Year</p><p className="text-sm font-medium">{form16.financialYear || ''}</p></div>
<div><p className="text-xs text-gray-500">Acknowledgement Number</p><p className="text-sm font-medium">{form16.acknowledgementNumber || ''}</p></div>
<div><p className="text-xs text-gray-500">Date of Issue</p><p className="text-sm font-medium">{form16.dateOfIssue || ''}</p></div>
<div><p className="text-xs text-gray-500">Quarter</p><p className="text-sm font-medium">{form16.quarter ? `${form16.quarter} (${form16.financialYear || ''})` : ''}</p></div>
</div>
<div>
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Deductor Details</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div><span className="text-gray-500">Name:</span> {form16.deductorName || ''}</div>
<div><span className="text-gray-500">TAN:</span> {form16.tanNumber || ''}</div>
</div>
</div>
{(form16.deduceeName || form16.deduceePan) && (
<div>
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Deductee Details (Dealer)</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{form16.deduceeName && <div><span className="text-gray-500">Name:</span> {form16.deduceeName}</div>}
{form16.deduceePan && <div><span className="text-gray-500">PAN:</span> {form16.deduceePan}</div>}
{form16.deduceeAddress && <div className="sm:col-span-2"><span className="text-gray-500">Address:</span> {form16.deduceeAddress}</div>}
</div>
</div>
)}
<div>
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">TDS Payment Details</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{form16.section && <div><span className="text-gray-500">Section:</span> {form16.section}</div>}
<div><span className="text-gray-500">Amount Paid:</span> {formatInr(form16.totalAmount ?? form16.amountPaid)}</div>
<div><span className="text-gray-500">TDS Deducted:</span> {formatInr(form16.tdsAmount)}</div>
{form16.dateOfPayment && <div><span className="text-gray-500">Date of Payment:</span> {form16.dateOfPayment}</div>}
</div>
</div>
{(form16.bsrCode || form16.challanSerialNo || form16.dateOfDeposit) && (
<div>
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Challan Details</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{form16.bsrCode && <div><span className="text-gray-500">BSR Code:</span> {form16.bsrCode}</div>}
{form16.challanSerialNo && <div><span className="text-gray-500">Challan Serial No.:</span> {form16.challanSerialNo}</div>}
{form16.dateOfDeposit && <div><span className="text-gray-500">Date of Deposit:</span> {form16.dateOfDeposit}</div>}
<div><span className="text-gray-500">Amount:</span> {formatInr(form16.tdsAmount)}</div>
</div>
</div>
)}
<div className="flex justify-between items-center pt-3 border-t border-emerald-200 bg-emerald-100/50 rounded-lg px-3 py-2">
<span className="text-sm font-semibold text-emerald-800">Total Tax Deducted (Form 16A)</span>
<span className="text-sm font-bold text-emerald-800">{formatInr(form16.tdsAmount)}</span>
</div>
</>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><p className="text-xs text-gray-500">Submission</p><p className="text-sm font-medium">{request?.title || 'Form 16A'}</p></div>
</div>
<div className="rounded-lg bg-emerald-100/50 border border-emerald-200 p-3 text-sm text-gray-700">
<p className="font-medium text-emerald-800 mb-1">Request details</p>
<FormattedDescription content={request?.description || ''} className="text-sm" />
</div>
<p className="text-xs text-gray-500">Full certificate details (from uploaded PDF) will appear here when available.</p>
</>
)}
</CardContent>
</Card>
)}
{/* Form 16 OCR extracted data show for duplicate / mismatch / partial when backend sends it */}
{isForm16 && ocrData && Object.keys(ocrData).length > 0 && (
<Card data-testid="form16-ocr-extracted-data" className="border-slate-200 bg-slate-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base text-slate-800">
<Scan className="w-4 h-4 text-slate-600" />
OCR extracted data
</CardTitle>
<CardDescription className="text-xs text-gray-600">Data extracted from the uploaded Form 16A PDF.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
{(ocrData.deductorName ?? ocrData.deductor_name ?? ocrData.nameAndAddressOfDeductor) != null && (
<div><p className="text-xs text-gray-500">Deductor name</p><p className="font-medium">{displayValue(ocrData.deductorName ?? ocrData.deductor_name ?? ocrData.nameAndAddressOfDeductor)}</p></div>
)}
{(ocrData.tanNumber ?? ocrData.tan_number ?? ocrData.tanOfDeductor) != null && (
<div><p className="text-xs text-gray-500">TAN</p><p className="font-medium">{displayValue(ocrData.tanNumber ?? ocrData.tan_number ?? ocrData.tanOfDeductor)}</p></div>
)}
{(ocrData.financialYear ?? ocrData.financial_year) != null && (
<div><p className="text-xs text-gray-500">Financial year</p><p className="font-medium">{displayValue(ocrData.financialYear ?? ocrData.financial_year)}</p></div>
)}
{ocrData.quarter != null && (
<div><p className="text-xs text-gray-500">Quarter</p><p className="font-medium">{displayValue(ocrData.quarter)}</p></div>
)}
{(ocrData.form16aNumber ?? ocrData.form16a_number) != null && (
<div><p className="text-xs text-gray-500">Form 16A number</p><p className="font-medium">{displayValue(ocrData.form16aNumber ?? ocrData.form16a_number)}</p></div>
)}
{(ocrData.totalAmount ?? ocrData.total_amount ?? ocrData.totalAmountPaid) != null && (
<div><p className="text-xs text-gray-500">Total amount</p><p className="font-medium">{formatInr((ocrData.totalAmount ?? ocrData.total_amount ?? ocrData.totalAmountPaid) as string | number | null)}</p></div>
)}
{(ocrData.totalTaxDeducted ?? ocrData.tdsAmount ?? ocrData.tds_amount) != null && (
<div><p className="text-xs text-gray-500">TDS / Tax deducted</p><p className="font-medium">{formatInr((ocrData.totalTaxDeducted ?? ocrData.tdsAmount ?? ocrData.tds_amount) as string | number | null)}</p></div>
)}
{(ocrData.natureOfPayment ?? ocrData.nature_of_payment) != null && (
<div><p className="text-xs text-gray-500">Nature of payment</p><p className="font-medium">{displayValue(ocrData.natureOfPayment ?? ocrData.nature_of_payment)}</p></div>
)}
{(ocrData.transactionDate ?? ocrData.transaction_date) != null && (
<div><p className="text-xs text-gray-500">Transaction date</p><p className="font-medium">{displayValue(ocrData.transactionDate ?? ocrData.transaction_date)}</p></div>
)}
{(ocrData.assessmentYear ?? ocrData.assessment_year) != null && (
<div><p className="text-xs text-gray-500">Assessment year</p><p className="font-medium">{displayValue(ocrData.assessmentYear ?? ocrData.assessment_year)}</p></div>
)}
{(ocrData.acknowledgementNumber ?? ocrData.acknowledgement_number) != null && (
<div><p className="text-xs text-gray-500">Acknowledgement number</p><p className="font-medium">{displayValue(ocrData.acknowledgementNumber ?? ocrData.acknowledgement_number)}</p></div>
)}
{(ocrData.dateOfIssue ?? ocrData.date_of_issue) != null && (
<div><p className="text-xs text-gray-500">Date of issue</p><p className="font-medium">{displayValue(ocrData.dateOfIssue ?? ocrData.date_of_issue)}</p></div>
)}
{(ocrData.deduceeName ?? ocrData.deducee_name) != null && (
<div><p className="text-xs text-gray-500">Deductee name</p><p className="font-medium">{displayValue(ocrData.deduceeName ?? ocrData.deducee_name)}</p></div>
)}
{(ocrData.deduceePan ?? ocrData.deducee_pan) != null && (
<div><p className="text-xs text-gray-500">Deductee PAN</p><p className="font-medium">{displayValue(ocrData.deduceePan ?? ocrData.deducee_pan)}</p></div>
)}
</div>
</CardContent>
</Card>
)}
{/* Request Initiator Card */} {/* Request Initiator Card */}
<Card data-testid="initiator-card"> <Card data-testid="initiator-card">
<CardHeader className="pb-3 sm:pb-4"> <CardHeader className="pb-3 sm:pb-4">

View File

@ -372,8 +372,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter, templateTypeFilter: filters.templateTypeFilter,
form16FinancialYear: filters.form16FinancialYear,
form16Quarter: filters.form16Quarter,
slaComplianceFilter: filters.slaComplianceFilter, slaComplianceFilter: filters.slaComplianceFilter,
departmentFilter: filters.departmentFilter, departmentFilter: filters.departmentFilter,
initiatorFilter: filters.initiatorFilter, initiatorFilter: filters.initiatorFilter,
@ -404,8 +402,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
prev.statusFilter !== filters.statusFilter || prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter || prev.priorityFilter !== filters.priorityFilter ||
prev.templateTypeFilter !== filters.templateTypeFilter || prev.templateTypeFilter !== filters.templateTypeFilter ||
prev.form16FinancialYear !== filters.form16FinancialYear ||
prev.form16Quarter !== filters.form16Quarter ||
prev.slaComplianceFilter !== filters.slaComplianceFilter || prev.slaComplianceFilter !== filters.slaComplianceFilter ||
prev.departmentFilter !== filters.departmentFilter || prev.departmentFilter !== filters.departmentFilter ||
prev.initiatorFilter !== filters.initiatorFilter || prev.initiatorFilter !== filters.initiatorFilter ||
@ -427,8 +423,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter, templateTypeFilter: filters.templateTypeFilter,
form16FinancialYear: filters.form16FinancialYear,
form16Quarter: filters.form16Quarter,
slaComplianceFilter: filters.slaComplianceFilter, slaComplianceFilter: filters.slaComplianceFilter,
departmentFilter: filters.departmentFilter, departmentFilter: filters.departmentFilter,
initiatorFilter: filters.initiatorFilter, initiatorFilter: filters.initiatorFilter,
@ -449,8 +443,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
filters.statusFilter, filters.statusFilter,
filters.priorityFilter, filters.priorityFilter,
filters.templateTypeFilter, filters.templateTypeFilter,
filters.form16FinancialYear,
filters.form16Quarter,
filters.slaComplianceFilter, filters.slaComplianceFilter,
filters.departmentFilter, filters.departmentFilter,
filters.initiatorFilter, filters.initiatorFilter,
@ -544,55 +536,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
<Separator /> <Separator />
{/* When Form 16 is selected: Template dropdown + Financial Year and Quarter only; other filters disabled */}
{filters.isForm16 ? (
<div className="flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium text-gray-700">All Templates</Label>
<Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
<SelectTrigger className="h-10 w-[180px]" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
<SelectItem value="FORM_16">Form 16</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium text-gray-700">Financial Year</Label>
<Select value={filters.form16FinancialYear} onValueChange={filters.setForm16FinancialYear}>
<SelectTrigger className="h-10 w-[140px]" data-testid="form16-financial-year-filter">
<SelectValue placeholder="Financial Year" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Years</SelectItem>
<SelectItem value="2024-25">2024-25</SelectItem>
<SelectItem value="2023-24">2023-24</SelectItem>
<SelectItem value="2022-23">2022-23</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium text-gray-700">Quarter</Label>
<Select value={filters.form16Quarter} onValueChange={filters.setForm16Quarter}>
<SelectTrigger className="h-10 w-[130px]" data-testid="form16-quarter-filter">
<SelectValue placeholder="Quarter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Quarters</SelectItem>
<SelectItem value="Q1">Q1</SelectItem>
<SelectItem value="Q2">Q2</SelectItem>
<SelectItem value="Q3">Q3</SelectItem>
<SelectItem value="Q4">Q4</SelectItem>
</SelectContent>
</Select>
</div>
</div>
) : (
<>
{/* Primary Filters */} {/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative md:col-span-3 lg:col-span-1"> <div className="relative md:col-span-3 lg:col-span-1">
@ -639,7 +582,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
<SelectItem value="all">All Templates</SelectItem> <SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
<SelectItem value="FORM_16">Form 16</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -674,7 +616,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
</Select> </Select>
</div> </div>
{/* User Filters - Initiator and Approver (hidden when Form 16 is selected) */} {/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */} {/* Initiator Filter */}
<div className="flex flex-col"> <div className="flex flex-col">
@ -900,8 +842,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
</Popover> </Popover>
)} )}
</div> </div>
</>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -30,10 +30,8 @@ import { TokenManager } from '@/utils/tokenManager';
// Services // Services
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService'; import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
import { getForm16Permissions } from '@/services/form16Api';
// Types // Types
import type { RequestTypeFilter } from './redux/requestsSlice';
import type { RequestsProps, BackendStats } from './types/requests.types'; import type { RequestsProps, BackendStats } from './types/requests.types';
export function UserAllRequests({ onViewRequest }: RequestsProps) { export function UserAllRequests({ onViewRequest }: RequestsProps) {
@ -59,45 +57,23 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Determine once - use this throughout instead of checking repeatedly // Determine once - use this throughout instead of checking repeatedly
const isDealer = userFilterType === 'DEALER'; const isDealer = userFilterType === 'DEALER';
// Helper to get filters for API - for dealer uses request type (All | Claim Management | Form 16)
// Helper to get filters for API - excludes dealer-restricted filters
// Since we know user type initially, this helper uses that knowledge
const getFiltersForApi = useCallback(() => { const getFiltersForApi = useCallback(() => {
const filterOptions = filters.getFilters(); const filterOptions = filters.getFilters();
if (!isDealer) return filterOptions; if (isDealer) {
const rt = filters.requestTypeFilter; // For dealers, exclude priority, templateType, department, and slaCompliance
if (rt === 'form_16') { const { priority, templateType, department, slaCompliance, ...dealerFilters } = filterOptions;
return { return dealerFilters;
templateType: 'FORM_16',
financialYear: filters.form16FinancialYear !== 'all' ? filters.form16FinancialYear : undefined,
quarter: filters.form16Quarter !== 'all' ? filters.form16Quarter : undefined,
};
} }
if (rt === 'claim_management') { return filterOptions;
return {
templateType: 'DEALER CLAIM',
search: filterOptions.search,
status: filterOptions.status !== 'all' ? filterOptions.status : undefined,
dateRange: filterOptions.dateRange,
startDate: filterOptions.startDate,
endDate: filterOptions.endDate,
initiator: filterOptions.initiator,
approver: filterOptions.approver,
approverType: filterOptions.approverType,
};
}
// All: show both Form 16 and Claim Management (no templateType filter)
const { priority, templateType, department, slaCompliance, ...dealerFilters } = filterOptions;
return dealerFilters;
}, [filters, isDealer]); }, [filters, isDealer]);
// Helper to calculate active filters count based on user type and request type (dealer)
// Helper to calculate active filters count based on user type
const calculateActiveFiltersCount = useCallback(() => { const calculateActiveFiltersCount = useCallback(() => {
if (isDealer) { if (isDealer) {
const rt = filters.requestTypeFilter; // For dealers: only count search, status, initiator, approver, and date filters
if (rt === 'form_16') {
return filters.form16FinancialYear !== 'all' || filters.form16Quarter !== 'all';
}
if (rt === 'claim_management') {
return filters.statusFilter !== 'all' || filters.dateRange !== 'all' || !!filters.customStartDate || !!filters.customEndDate;
}
return !!( return !!(
filters.searchTerm || filters.searchTerm ||
filters.statusFilter !== 'all' || filters.statusFilter !== 'all' ||
@ -108,6 +84,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
filters.customEndDate filters.customEndDate
); );
} }
// For standard users: count all filters (use existing hasActiveFilters)
return filters.hasActiveFilters; return filters.hasActiveFilters;
}, [isDealer, filters]); }, [isDealer, filters]);
@ -118,7 +95,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
const [departments, setDepartments] = useState<string[]>([]); const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false); const [loadingDepartments, setLoadingDepartments] = useState(false);
const [showForm16Filter, setShowForm16Filter] = useState(false);
// Pagination (currentPage now in Redux) // Pagination (currentPage now in Redux)
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@ -202,13 +178,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
} }
}, []); }, []);
// Form 16 permissions: show Form 16 in template filter when user has Form 16 access (standard users)
useEffect(() => {
if (userFilterType !== 'STANDARD') return;
getForm16Permissions()
.then((p) => setShowForm16Filter(!!(p.canViewForm16Submission || p.canView26AS)))
.catch(() => setShowForm16Filter(false));
}, [userFilterType]);
// Use refs to store stable callbacks to prevent infinite loops // Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
@ -490,13 +459,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
{/* Filters - Plug-and-play pattern */} {/* Filters - Plug-and-play pattern */}
<UserAllRequestsFiltersComponent <UserAllRequestsFiltersComponent
requestTypeFilter={filters.requestTypeFilter}
onRequestTypeChange={(value: RequestTypeFilter | string) => filters.setRequestTypeFilter(value as RequestTypeFilter)}
showForm16Filter={showForm16Filter}
form16FinancialYear={filters.form16FinancialYear}
form16Quarter={filters.form16Quarter}
onForm16FinancialYearChange={filters.setForm16FinancialYear}
onForm16QuarterChange={filters.setForm16Quarter}
searchTerm={filters.searchTerm} searchTerm={filters.searchTerm}
statusFilter={filters.statusFilter} statusFilter={filters.statusFilter}
priorityFilter={filters.priorityFilter} priorityFilter={filters.priorityFilter}

View File

@ -47,23 +47,8 @@ interface RequestCardProps {
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void; onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
} }
function formatInr(val: number | null | undefined): string {
if (val == null) return '—';
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(val);
}
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) { export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
const isForm16 = (request?.templateType || (request as any)?.template_type || '').toString().toUpperCase() === 'FORM_16'; const statusConfig = getStatusConfig(request.status);
const form16 = (request as any)?.form16Submission;
const form16DisplayStatus = form16?.displayStatus;
const isForm16MismatchOrFailed = form16DisplayStatus && /balance mismatch|failed/i.test(String(form16DisplayStatus));
const statusConfig = isForm16 && form16DisplayStatus
? {
color: isForm16MismatchOrFailed ? 'bg-red-100 !text-red-800 border-red-200' : form16DisplayStatus === 'Completed' ? 'bg-green-100 !text-green-800 border-green-200' : 'bg-gray-100 !text-gray-700 border-gray-200',
icon: getStatusConfig(request.status).icon,
label: form16DisplayStatus,
}
: getStatusConfig(request.status);
const priorityConfig = getPriorityConfig(request.priority); const priorityConfig = getPriorityConfig(request.priority);
const StatusIcon = statusConfig.icon; const StatusIcon = statusConfig.icon;
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
@ -97,7 +82,7 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
data-testid="status-badge" data-testid="status-badge"
> >
<StatusIcon className="w-3 h-3 mr-1" /> <StatusIcon className="w-3 h-3 mr-1" />
<span className="capitalize">{statusConfig.label}</span> <span className="capitalize">{request.status}</span>
</Badge> </Badge>
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && ( {((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
<Badge <Badge
@ -117,10 +102,11 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<PriorityIcon className="w-3 h-3 mr-1" /> <PriorityIcon className="w-3 h-3 mr-1" />
{request.priority} {request.priority}
</Badge> </Badge>
{/* Template Type Badge Form 16 shows green "Form 16", others unchanged */} {/* Template Type Badge */}
{(() => { {(() => {
const templateType = request?.templateType || (request as any)?.template_type || ''; const templateType = request?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType
let templateLabel = 'Non-Templatized'; let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200'; let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
@ -128,9 +114,6 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
if (templateTypeUpper === 'DEALER CLAIM') { if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim'; templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200'; templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'FORM_16') {
templateLabel = 'Form 16';
templateColor = 'bg-emerald-100 !text-emerald-700 border-emerald-200';
} else if (templateTypeUpper === 'TEMPLATE') { } else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template'; templateLabel = 'Template';
} }
@ -168,47 +151,26 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<span className="truncate" data-testid="submitted-date"> <span className="truncate" data-testid="submitted-date">
<span className="font-medium">Submitted:</span> {formatDateDDMMYYYY(request.submittedDate)} <span className="font-medium">Submitted:</span> {formatDateDDMMYYYY(request.submittedDate)}
</span> </span>
{isForm16 && form16 && (
<>
<span className="truncate" data-testid="form16-total-amount">
<span className="font-medium">Total amount:</span> {form16.totalAmount != null ? formatInr(form16.totalAmount) : '—'}
</span>
<span className="truncate" data-testid="form16-credit-note">
<span className="font-medium">Credit note:</span> {form16.creditNoteNumber || '—'}
</span>
</>
)}
</div> </div>
</div> </div>
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 mt-1" /> <ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 mt-1" />
</div> </div>
{/* Current Approver and Level Info Form 16 shows "Form 16 OCR FLOW" instead */} {/* Current Approver and Level Info */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 pt-3 border-t border-gray-100"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 pt-3 border-t border-gray-100">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
{(request?.templateType || (request as any)?.template_type || '').toString().toUpperCase() === 'FORM_16' ? ( <div className="flex items-center gap-2 min-w-0">
<div className="flex items-center gap-2 min-w-0"> <User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-emerald-600 flex-shrink-0" /> <span className="text-xs sm:text-sm truncate" data-testid="current-approver">
<span className="text-xs sm:text-sm font-medium text-emerald-700" data-testid="form16-ocr-flow"> <span className="text-gray-500">Current Approver:</span> <span className="text-gray-900 font-medium">{request.currentApprover}</span>
Form 16 OCR FLOW </span>
</span> </div>
</div> <div className="flex items-center gap-2">
) : ( <TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
<> <span className="text-xs sm:text-sm" data-testid="approval-level">
<div className="flex items-center gap-2 min-w-0"> <span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" /> </span>
<span className="text-xs sm:text-sm truncate" data-testid="current-approver"> </div>
<span className="text-gray-500">Current Approver:</span> <span className="text-gray-900 font-medium">{request.currentApprover}</span>
</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
<span className="text-xs sm:text-sm" data-testid="approval-level">
<span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
</span>
</div>
</>
)}
</div> </div>
<div className="flex items-center gap-1.5 text-xs text-gray-500"> <div className="flex items-center gap-1.5 text-xs text-gray-500">
<Clock className="w-3.5 h-3.5 flex-shrink-0" /> <Clock className="w-3.5 h-3.5 flex-shrink-0" />

View File

@ -7,15 +7,11 @@ import { useCallback } from 'react';
import { useAppSelector, useAppDispatch } from '@/redux/hooks'; import { useAppSelector, useAppDispatch } from '@/redux/hooks';
import type { DateRange } from '@/services/dashboard.service'; import type { DateRange } from '@/services/dashboard.service';
import type { RequestFilters } from '../types/requests.types'; import type { RequestFilters } from '../types/requests.types';
import type { RequestTypeFilter } from '../redux/requestsSlice';
import { import {
setSearchTerm as setSearchTermAction, setSearchTerm as setSearchTermAction,
setStatusFilter as setStatusFilterAction, setStatusFilter as setStatusFilterAction,
setPriorityFilter as setPriorityFilterAction, setPriorityFilter as setPriorityFilterAction,
setTemplateTypeFilter as setTemplateTypeFilterAction, setTemplateTypeFilter as setTemplateTypeFilterAction,
setRequestTypeFilter as setRequestTypeFilterAction,
setForm16FinancialYear as setForm16FinancialYearAction,
setForm16Quarter as setForm16QuarterAction,
setSlaComplianceFilter as setSlaComplianceFilterAction, setSlaComplianceFilter as setSlaComplianceFilterAction,
setDepartmentFilter as setDepartmentFilterAction, setDepartmentFilter as setDepartmentFilterAction,
setInitiatorFilter as setInitiatorFilterAction, setInitiatorFilter as setInitiatorFilterAction,
@ -38,9 +34,6 @@ export function useRequestsFilters() {
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter, templateTypeFilter,
requestTypeFilter,
form16FinancialYear,
form16Quarter,
slaComplianceFilter, slaComplianceFilter,
departmentFilter, departmentFilter,
initiatorFilter, initiatorFilter,
@ -58,9 +51,6 @@ export function useRequestsFilters() {
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]); const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]); const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]); const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
const setRequestTypeFilter = useCallback((value: RequestTypeFilter) => dispatch(setRequestTypeFilterAction(value)), [dispatch]);
const setForm16FinancialYear = useCallback((value: string) => dispatch(setForm16FinancialYearAction(value)), [dispatch]);
const setForm16Quarter = useCallback((value: string) => dispatch(setForm16QuarterAction(value)), [dispatch]);
const setSlaComplianceFilter = useCallback((value: string) => dispatch(setSlaComplianceFilterAction(value)), [dispatch]); const setSlaComplianceFilter = useCallback((value: string) => dispatch(setSlaComplianceFilterAction(value)), [dispatch]);
const setDepartmentFilter = useCallback((value: string) => dispatch(setDepartmentFilterAction(value)), [dispatch]); const setDepartmentFilter = useCallback((value: string) => dispatch(setDepartmentFilterAction(value)), [dispatch]);
const setInitiatorFilter = useCallback((value: string) => dispatch(setInitiatorFilterAction(value)), [dispatch]); const setInitiatorFilter = useCallback((value: string) => dispatch(setInitiatorFilterAction(value)), [dispatch]);
@ -73,14 +63,6 @@ export function useRequestsFilters() {
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]); const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
const getFilters = useCallback((): RequestFilters => { const getFilters = useCallback((): RequestFilters => {
const isForm16 = templateTypeFilter === 'FORM_16';
if (isForm16) {
return {
templateType: 'FORM_16',
financialYear: form16FinancialYear !== 'all' ? form16FinancialYear : undefined,
quarter: form16Quarter !== 'all' ? form16Quarter : undefined,
};
}
return { return {
search: searchTerm || undefined, search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined, status: statusFilter !== 'all' ? statusFilter : undefined,
@ -100,8 +82,6 @@ export function useRequestsFilters() {
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter, templateTypeFilter,
form16FinancialYear,
form16Quarter,
slaComplianceFilter, slaComplianceFilter,
departmentFilter, departmentFilter,
initiatorFilter, initiatorFilter,
@ -139,24 +119,19 @@ export function useRequestsFilters() {
} }
}, [customStartDate, customEndDate, dispatch]); }, [customStartDate, customEndDate, dispatch]);
const isForm16 = templateTypeFilter === 'FORM_16'; const hasActiveFilters: boolean = !!(
const hasActiveFilters: boolean = isForm16 searchTerm ||
? !!(templateTypeFilter === 'FORM_16' && (form16FinancialYear !== 'all' || form16Quarter !== 'all')) statusFilter !== 'all' ||
: !!( priorityFilter !== 'all' ||
searchTerm || templateTypeFilter !== 'all' ||
statusFilter !== 'all' || slaComplianceFilter !== 'all' ||
priorityFilter !== 'all' || departmentFilter !== 'all' ||
templateTypeFilter !== 'all' || initiatorFilter !== 'all' ||
requestTypeFilter !== 'all' || approverFilter !== 'all' ||
((requestTypeFilter as RequestTypeFilter) === 'form_16' && (form16FinancialYear !== 'all' || form16Quarter !== 'all')) || dateRange !== 'all' ||
slaComplianceFilter !== 'all' || customStartDate ||
departmentFilter !== 'all' || customEndDate
initiatorFilter !== 'all' || );
approverFilter !== 'all' ||
dateRange !== 'all' ||
customStartDate ||
customEndDate
);
return { return {
// State // State
@ -164,10 +139,6 @@ export function useRequestsFilters() {
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter, templateTypeFilter,
requestTypeFilter,
form16FinancialYear,
form16Quarter,
isForm16,
slaComplianceFilter, slaComplianceFilter,
departmentFilter, departmentFilter,
initiatorFilter, initiatorFilter,
@ -184,9 +155,6 @@ export function useRequestsFilters() {
setStatusFilter, setStatusFilter,
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter, setTemplateTypeFilter,
setRequestTypeFilter,
setForm16FinancialYear,
setForm16Quarter,
setSlaComplianceFilter, setSlaComplianceFilter,
setDepartmentFilter, setDepartmentFilter,
setInitiatorFilter, setInitiatorFilter,

View File

@ -1,20 +1,11 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { DateRange } from '@/services/dashboard.service'; import type { DateRange } from '@/services/dashboard.service';
/** Dealer All Requests: main filter to switch between All, Claim Management, Form 16. */
export type RequestTypeFilter = 'all' | 'claim_management' | 'form_16';
export interface RequestsFiltersState { export interface RequestsFiltersState {
searchTerm: string; searchTerm: string;
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
/** Dealer only: request type for All Requests (all | claim_management | form_16). */
requestTypeFilter: RequestTypeFilter;
/** When template is Form 16: financial year (e.g. 2024-25) */
form16FinancialYear: string;
/** When template is Form 16: quarter (Q1, Q2, Q3, Q4) */
form16Quarter: string;
slaComplianceFilter: string; slaComplianceFilter: string;
departmentFilter: string; departmentFilter: string;
initiatorFilter: string; initiatorFilter: string;
@ -32,9 +23,6 @@ const initialState: RequestsFiltersState = {
statusFilter: 'all', statusFilter: 'all',
priorityFilter: 'all', priorityFilter: 'all',
templateTypeFilter: 'all', templateTypeFilter: 'all',
requestTypeFilter: 'all',
form16FinancialYear: 'all',
form16Quarter: 'all',
slaComplianceFilter: 'all', slaComplianceFilter: 'all',
departmentFilter: 'all', departmentFilter: 'all',
initiatorFilter: 'all', initiatorFilter: 'all',
@ -62,23 +50,6 @@ const requestsSlice = createSlice({
}, },
setTemplateTypeFilter: (state, action: PayloadAction<string>) => { setTemplateTypeFilter: (state, action: PayloadAction<string>) => {
state.templateTypeFilter = action.payload; state.templateTypeFilter = action.payload;
if (action.payload !== 'FORM_16') {
state.form16FinancialYear = 'all';
state.form16Quarter = 'all';
}
},
setRequestTypeFilter: (state, action: PayloadAction<RequestTypeFilter>) => {
state.requestTypeFilter = action.payload;
if (action.payload !== 'form_16') {
state.form16FinancialYear = 'all';
state.form16Quarter = 'all';
}
},
setForm16FinancialYear: (state, action: PayloadAction<string>) => {
state.form16FinancialYear = action.payload;
},
setForm16Quarter: (state, action: PayloadAction<string>) => {
state.form16Quarter = action.payload;
}, },
setSlaComplianceFilter: (state, action: PayloadAction<string>) => { setSlaComplianceFilter: (state, action: PayloadAction<string>) => {
state.slaComplianceFilter = action.payload; state.slaComplianceFilter = action.payload;
@ -115,9 +86,6 @@ const requestsSlice = createSlice({
state.statusFilter = 'all'; state.statusFilter = 'all';
state.priorityFilter = 'all'; state.priorityFilter = 'all';
state.templateTypeFilter = 'all'; state.templateTypeFilter = 'all';
state.requestTypeFilter = 'all';
state.form16FinancialYear = 'all';
state.form16Quarter = 'all';
state.slaComplianceFilter = 'all'; state.slaComplianceFilter = 'all';
state.departmentFilter = 'all'; state.departmentFilter = 'all';
state.initiatorFilter = 'all'; state.initiatorFilter = 'all';
@ -137,9 +105,6 @@ export const {
setStatusFilter, setStatusFilter,
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter, setTemplateTypeFilter,
setRequestTypeFilter,
setForm16FinancialYear,
setForm16Quarter,
setSlaComplianceFilter, setSlaComplianceFilter,
setDepartmentFilter, setDepartmentFilter,
setInitiatorFilter, setInitiatorFilter,

View File

@ -26,10 +26,6 @@ export async function fetchRequestsData({
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status; if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority; if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType; if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
if (filters?.templateType === 'FORM_16') {
if (filters?.financialYear) backendFilters.financialYear = filters.financialYear;
if (filters?.quarter) backendFilters.quarter = filters.quarter;
}
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department; if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator; if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.approver && filters.approver !== 'all') { if (filters?.approver && filters.approver !== 'all') {
@ -96,10 +92,6 @@ export async function fetchRequestsData({
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status; if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority; if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType; if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
if (filters?.templateType === 'FORM_16') {
if (filters?.financialYear) backendFilters.financialYear = filters.financialYear;
if (filters?.quarter) backendFilters.quarter = filters.quarter;
}
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department; if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator; if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance; if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;

View File

@ -31,8 +31,6 @@ export async function fetchUserParticipantRequestsData({
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status; if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority; if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType; if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
if (filters?.financialYear) backendFilters.financialYear = filters.financialYear;
if (filters?.quarter) backendFilters.quarter = filters.quarter;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department; if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator; if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.approver && filters.approver !== 'all') { if (filters?.approver && filters.approver !== 'all') {
@ -105,8 +103,6 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status; if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority; if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType; if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
if (filters?.financialYear) backendFilters.financialYear = filters.financialYear;
if (filters?.quarter) backendFilters.quarter = filters.quarter;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department; if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator; if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.approver && filters.approver !== 'all') { if (filters?.approver && filters.approver !== 'all') {

View File

@ -13,10 +13,6 @@ export interface RequestFilters {
status?: string; status?: string;
priority?: string; priority?: string;
templateType?: string; templateType?: string;
/** When templateType is FORM_16, filter by Form 16A submission financial year */
financialYear?: string;
/** When templateType is FORM_16, filter by Form 16A submission quarter */
quarter?: string;
slaCompliance?: string; slaCompliance?: string;
department?: string; department?: string;
initiator?: string; initiator?: string;
@ -53,13 +49,6 @@ export interface User {
displayName?: string; displayName?: string;
} }
/** Form 16 submission summary for listing (total amount, credit note, display status) */
export interface Form16SubmissionSummary {
totalAmount?: number | null;
creditNoteNumber?: string | null;
displayStatus?: string;
}
export interface ConvertedRequest { export interface ConvertedRequest {
id: string; id: string;
requestId: string; requestId: string;
@ -76,7 +65,5 @@ export interface ConvertedRequest {
templateType?: string; templateType?: string;
workflowType?: string; workflowType?: string;
templateName?: string; templateName?: string;
/** Form 16: total amount, credit note number, display status (Balance mismatch, Failed, etc.) */
form16Submission?: Form16SubmissionSummary | null;
} }

View File

@ -27,47 +27,41 @@ export const getPriorityConfig = (priority: string) => {
} }
}; };
export const getStatusConfig = (status: string): { color: string; label: string; icon: typeof CheckCircle; iconColor: string } => { export const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'approved': case 'approved':
return { return {
color: 'bg-green-100 text-green-800 border-green-200', color: 'bg-green-100 text-green-800 border-green-200',
label: 'approved',
icon: CheckCircle, icon: CheckCircle,
iconColor: 'text-green-600' iconColor: 'text-green-600'
}; };
case 'rejected': case 'rejected':
return { return {
color: 'bg-red-100 text-red-800 border-red-200', color: 'bg-red-100 text-red-800 border-red-200',
label: 'rejected',
icon: XCircle, icon: XCircle,
iconColor: 'text-red-600' iconColor: 'text-red-600'
}; };
case 'pending': case 'pending':
return { return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200', color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
label: 'pending',
icon: Clock, icon: Clock,
iconColor: 'text-yellow-600' iconColor: 'text-yellow-600'
}; };
case 'closed': case 'closed':
return { return {
color: 'bg-gray-100 text-gray-800 border-gray-200', color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'closed',
icon: CheckCircle, icon: CheckCircle,
iconColor: 'text-gray-600' iconColor: 'text-gray-600'
}; };
case 'draft': case 'draft':
return { return {
color: 'bg-gray-100 text-gray-800 border-gray-200', color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'draft',
icon: Edit, icon: Edit,
iconColor: 'text-gray-600' iconColor: 'text-gray-600'
}; };
default: default:
return { return {
color: 'bg-gray-100 text-gray-800 border-gray-200', color: 'bg-gray-100 text-gray-800 border-gray-200',
label: status,
icon: AlertCircle, icon: AlertCircle,
iconColor: 'text-gray-600' iconColor: 'text-gray-600'
}; };

View File

@ -99,8 +99,7 @@ export function transformRequest(req: any): ConvertedRequest {
approverLevel: approverLevel, approverLevel: approverLevel,
templateType: req.templateType || req.template_type, templateType: req.templateType || req.template_type,
workflowType: req.workflowType || req.workflow_type, workflowType: req.workflowType || req.workflow_type,
templateName: req.templateName || req.template_name, templateName: req.templateName || req.template_name
form16Submission: req.form16Submission ?? null,
}; };
} }

View File

@ -17,7 +17,6 @@ import { ConfigurationManager } from '@/components/admin/ConfigurationManager';
import { HolidayManager } from '@/components/admin/HolidayManager'; import { HolidayManager } from '@/components/admin/HolidayManager';
import { UserRoleManager } from '@/components/admin/UserRoleManager'; import { UserRoleManager } from '@/components/admin/UserRoleManager';
import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager'; import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager';
import { Form16AdminConfig } from '@/components/admin/Form16AdminConfig/Form16AdminConfig';
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal'; import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal'; import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
// import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page // import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page
@ -37,7 +36,6 @@ export function Settings() {
const [hasSubscription, setHasSubscription] = useState(false); const [hasSubscription, setHasSubscription] = useState(false);
const [checkingSubscription, setCheckingSubscription] = useState(true); const [checkingSubscription, setCheckingSubscription] = useState(true);
const [showActivityTypeManager, setShowActivityTypeManager] = useState(false); const [showActivityTypeManager, setShowActivityTypeManager] = useState(false);
const [showForm16AdminConfig, setShowForm16AdminConfig] = useState(false);
useEffect(() => { useEffect(() => {
checkSubscriptionStatus(); checkSubscriptionStatus();
@ -361,71 +359,7 @@ export function Settings() {
{/* Template Settings Tab (Admin Only) */} {/* Template Settings Tab (Admin Only) */}
<TabsContent value="templates" className="mt-0"> <TabsContent value="templates" className="mt-0">
{showForm16AdminConfig ? ( {!showActivityTypeManager ? (
<div className="space-y-4">
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowForm16AdminConfig(false)}
className="gap-2 hover:bg-slate-100"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back
</Button>
<div className="p-2.5 bg-gradient-to-br from-emerald-600 to-emerald-700 rounded-md shadow-md">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg sm:text-xl font-semibold text-slate-900">Form 16 Administration</CardTitle>
<CardDescription className="text-sm">
Configure Form 16 access, who can view submission data and 26AS, and notification settings
</CardDescription>
</div>
</div>
</div>
</CardHeader>
</Card>
<Form16AdminConfig />
</div>
) : showActivityTypeManager ? (
<div className="space-y-4">
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowActivityTypeManager(false)}
className="gap-2 hover:bg-slate-100"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back
</Button>
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg sm:text-xl font-semibold text-slate-900">Dealer Claim Activity Settings</CardTitle>
<CardDescription className="text-sm">
Manage activity types for dealer claim workflows
</CardDescription>
</div>
</div>
</div>
</CardHeader>
</Card>
<ActivityTypeManager />
</div>
) : (
<Card className="shadow-lg border-0 rounded-md"> <Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5"> <CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -470,37 +404,41 @@ export function Settings() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Form 16 Administration Card */}
<Card
className="shadow-md hover:shadow-lg transition-all duration-300 border border-slate-200 rounded-lg cursor-pointer group"
onClick={() => setShowForm16AdminConfig(true)}
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-lg shadow-md group-hover:shadow-lg transition-shadow">
<FileText className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 group-hover:text-emerald-600 transition-colors">
Form 16
</h3>
<p className="text-sm text-slate-600 mt-1">
Configure Form 16 access, 26AS viewers, and notification settings
</p>
</div>
</div>
<div className="text-slate-400 group-hover:text-emerald-600 transition-colors">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</CardContent>
</Card>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) : (
<div className="space-y-4">
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowActivityTypeManager(false)}
className="gap-2 hover:bg-slate-100"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back
</Button>
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg sm:text-xl font-semibold text-slate-900">Dealer Claim Activity Settings</CardTitle>
<CardDescription className="text-sm">
Manage activity types for dealer claim workflows
</CardDescription>
</div>
</div>
</div>
</CardHeader>
</Card>
<ActivityTypeManager />
</div>
)} )}
</TabsContent> </TabsContent>
</div> </div>

View File

@ -83,55 +83,6 @@ export const resetConfiguration = async (configKey: string): Promise<void> => {
await apiClient.post(`/admin/configurations/${configKey}/reset`); await apiClient.post(`/admin/configurations/${configKey}/reset`);
}; };
export interface Form16NotificationItem {
enabled: boolean;
template?: string;
}
/** 26AS data added: separate message for RE users and for dealers */
export interface Form16Notification26AsItem {
enabled: boolean;
templateRe?: string;
templateDealers?: string;
}
export interface Form16AdminConfig {
submissionViewerEmails: string[];
twentySixAsViewerEmails: string[];
reminderEnabled: boolean;
reminderDays: number;
notification26AsDataAdded?: Form16NotificationItem | Form16Notification26AsItem;
notificationForm16SuccessCreditNote?: Form16NotificationItem;
notificationForm16Unsuccessful?: Form16NotificationItem;
alertSubmitForm16Enabled?: boolean;
alertSubmitForm16FrequencyDays?: number;
alertSubmitForm16FrequencyHours?: number;
/** When to run the alert job daily (HH:mm, 24h, server timezone). */
alertSubmitForm16RunAtTime?: string;
alertSubmitForm16Template?: string;
reminderNotificationEnabled?: boolean;
reminderFrequencyDays?: number;
reminderFrequencyHours?: number;
/** When to run the reminder job daily (HH:mm, 24h, server timezone). */
reminderRunAtTime?: string;
reminderNotificationTemplate?: string;
}
/**
* Get Form 16 admin configuration
*/
export const getForm16Config = async (): Promise<Form16AdminConfig> => {
const response = await apiClient.get<{ data: Form16AdminConfig }>('/admin/form16-config');
return response.data.data ?? response.data;
};
/**
* Update Form 16 admin configuration
*/
export const putForm16Config = async (config: Partial<Form16AdminConfig>): Promise<void> => {
await apiClient.put('/admin/form16-config', config);
};
/** /**
* Get all holidays * Get all holidays
*/ */

View File

@ -22,14 +22,8 @@ const apiClient: AxiosInstance = axios.create({
// In development: Add Authorization header from localStorage // In development: Add Authorization header from localStorage
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config) => { (config) => {
// FormData: do not set Content-Type so the browser sets multipart/form-data with boundary // In production, cookies are sent automatically with withCredentials: true
if (config.data instanceof FormData) { // No need to set Authorization header
const h = config.headers as Record<string, unknown> & { common?: Record<string, unknown>; post?: Record<string, unknown> };
delete h['Content-Type'];
if (h.common && typeof h.common === 'object') delete h.common['Content-Type'];
if (h.post && typeof h.post === 'object') delete h.post['Content-Type'];
}
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
if (!isProduction) { if (!isProduction) {
@ -140,26 +134,6 @@ export interface RefreshTokenResponse {
accessToken: string; accessToken: string;
} }
/**
* Login with username and password (e.g. local dealer TESTREFLOW).
* Stores user + tokens the same way as token exchange.
*/
export async function passwordLogin(username: string, password: string): Promise<TokenExchangeResponse> {
const response = await apiClient.post<{ data?: TokenExchangeResponse }>(
'/auth/login',
{ username, password },
{ withCredentials: true }
);
const data = response.data as any;
const result = data.data || data;
if (result.user) TokenManager.setUserData(result.user);
if (result.accessToken && result.refreshToken) {
TokenManager.setAccessToken(result.accessToken);
TokenManager.setRefreshToken(result.refreshToken);
}
return result;
}
/** /**
* Exchange authorization code for tokens (localhost only) * Exchange authorization code for tokens (localhost only)
*/ */

View File

@ -401,16 +401,3 @@ export async function getDealerDashboard(
} }
} }
/**
* Re-trigger WFM CSV push
* POST /api/v1/dealer-claims/:requestId/wfm/retrigger
*/
export async function retriggerWFMPush(requestId: string): Promise<any> {
try {
const response = await apiClient.post(`/dealer-claims/${requestId}/wfm/retrigger`);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error re-triggering WFM push:', error);
throw error;
}
}

View File

@ -1,693 +0,0 @@
/**
* Form 16 API credit notes and (later) submissions.
* Uses apiClient for JSON; uses fetch for FormData (so browser sets multipart boundary).
*/
import apiClient from './authApi';
import { TokenManager } from '@/utils/tokenManager';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
/**
* REform16 pattern: authenticated request; do NOT set Content-Type for FormData (browser sets multipart/form-data + boundary).
*/
async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<{ data?: unknown; message?: string; success?: boolean }> {
const token = TokenManager.getAccessToken();
const headers: HeadersInit = {
...(options.headers || {}),
};
if (!(options.body instanceof FormData)) {
(headers as Record<string, string>)['Content-Type'] = 'application/json';
}
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers,
credentials: 'include',
});
const contentType = response.headers.get('content-type');
const isJson = contentType?.includes('application/json');
const data = isJson ? await response.json() : { message: await response.text() || 'Request failed' };
if (!response.ok) {
const err = new Error((data as { message?: string }).message || `Request failed ${response.status}`) as Error & { response?: { status: number; data?: unknown } };
err.response = { status: response.status, data };
throw err;
}
return data as { data?: unknown; message?: string; success?: boolean };
}
export interface Form16CreditNoteSubmission {
requestId: string;
form16aNumber: string | null;
financialYear: string | null;
quarter: string | null;
status: string | null;
}
export interface Form16CreditNoteItem {
id: string;
creditNoteNumber: string | null;
sapDocumentNumber: string | null;
amount: number | null;
issueDate: string | null;
financialYear: string | null;
quarter: string | null;
status: string | null;
remarks: string | null;
submission: Form16CreditNoteSubmission | null;
/** RE view: dealer code (when listing all credit notes) */
dealerCode?: string | null;
dealerName?: string | null;
}
export interface ListCreditNotesSummary {
totalCreditNotes: number;
totalAmount: number;
activeDealersCount: number;
}
export interface ListCreditNotesResponse {
creditNotes: Form16CreditNoteItem[];
total: number;
summary?: ListCreditNotesSummary;
}
export interface ListCreditNotesParams {
financialYear?: string;
quarter?: string;
}
export interface Form16Permissions {
canViewForm16Submission: boolean;
canView26AS: boolean;
}
/**
* Get Form 16 permissions for the current user (API-driven from admin config).
* Use to show/hide Form 16 menu, 26AS, and submission data in the UI.
*/
export async function getForm16Permissions(): Promise<Form16Permissions> {
const res = await apiClient.get<{ data: Form16Permissions }>('/form16/permissions');
const data = res.data?.data ?? res.data;
return {
canViewForm16Submission: !!data?.canViewForm16Submission,
canView26AS: !!data?.canView26AS,
};
}
/**
* List credit notes for the authenticated dealer (resolved by backend from user email).
*/
export async function listCreditNotes(params?: ListCreditNotesParams): Promise<ListCreditNotesResponse> {
const searchParams = new URLSearchParams();
if (params?.financialYear) searchParams.set('financialYear', params.financialYear);
if (params?.quarter) searchParams.set('quarter', params.quarter);
const query = searchParams.toString();
const url = query ? `/form16/credit-notes?${query}` : '/form16/credit-notes';
const { data } = await apiClient.get<{ data?: ListCreditNotesResponse; creditNotes?: Form16CreditNoteItem[]; total?: number; summary?: ListCreditNotesSummary }>(url);
const payload = data?.data ?? data;
return {
creditNotes: payload?.creditNotes ?? [],
total: payload?.total ?? 0,
summary: payload?.summary,
};
}
/** Get credit note linked to a Form 16 request (for workflow tab). */
export async function getCreditNoteByRequestId(requestId: string): Promise<Form16CreditNoteItem | null> {
const { data } = await apiClient.get<{ data?: { creditNote?: Form16CreditNoteItem | null }; creditNote?: Form16CreditNoteItem | null }>(`/form16/requests/${encodeURIComponent(requestId)}/credit-note`);
const payload = data?.data ?? data;
return payload?.creditNote ?? null;
}
/** RE only. Cancel Form 16 submission and mark workflow rejected. */
export async function cancelForm16Submission(requestId: string): Promise<void> {
await apiClient.post(`/form16/requests/${encodeURIComponent(requestId)}/cancel-submission`);
}
/** RE only. Mark Form 16 submission as resubmission needed. */
export async function setForm16ResubmissionNeeded(requestId: string): Promise<void> {
await apiClient.post(`/form16/requests/${encodeURIComponent(requestId)}/resubmission-needed`);
}
/** RE only. Manually generate credit note for Form 16 request. Body: { amount: number }. */
export async function generateForm16CreditNoteManually(requestId: string, amount: number): Promise<{ creditNote: Form16CreditNoteItem }> {
const { data } = await apiClient.post<{ data?: { creditNote?: Form16CreditNoteItem }; creditNote?: Form16CreditNoteItem }>(
`/form16/requests/${encodeURIComponent(requestId)}/generate-credit-note`,
{ amount }
);
const payload = data?.data ?? data;
const creditNote = payload?.creditNote;
if (!creditNote) throw new Error('Credit note not returned');
return { creditNote };
}
/** Get a single credit note by id with dealer info and dealer transaction history. */
export interface CreditNoteDetailResponse {
creditNote: Form16CreditNoteItem & { submission?: { submittedDate?: string | null } };
dealerName: string;
dealerEmail: string;
dealerContact: string;
/** Present when a debit note was issued for this credit note */
debitNote?: Form16DebitNoteItem | null;
dealerCreditNotes: Array<{
id: number;
creditNoteNumber: string | null;
amount: number | null;
issueDate: string | null;
status: string | null;
form16aNumber?: string | null;
submittedDate?: string | null;
}>;
}
export async function getCreditNoteById(id: number): Promise<CreditNoteDetailResponse> {
const { data } = await apiClient.get<{ data?: CreditNoteDetailResponse } | CreditNoteDetailResponse>(`/form16/credit-notes/${id}`);
const payload = (data && typeof data === 'object' && 'data' in data ? data.data : data) as CreditNoteDetailResponse | undefined;
if (!payload?.creditNote) throw new Error('Credit note not found');
return payload;
}
// ---------- Form 16 SAP simulation (replace with real SAP when integrating) ----------
/** Dealer details for SAP credit note simulation */
export interface SapCreditNoteDealerDetails {
dealerCode: string;
dealerName?: string | null;
dealerEmail?: string | null;
dealerContact?: string | null;
}
/** Simulated SAP credit note response (JSON from SAP) */
export interface SapCreditNoteResponse {
success: boolean;
creditNoteNumber: string;
sapDocumentNumber: string;
amount: number;
issueDate: string;
financialYear?: string;
quarter?: string;
status: string;
message?: string;
}
/** Dealer info for SAP debit note simulation */
export interface SapDebitNoteDealerInfo {
dealerCode: string;
dealerName?: string | null;
dealerEmail?: string | null;
dealerContact?: string | null;
}
/** Simulated SAP debit note response (JSON from SAP) */
export interface SapDebitNoteResponse {
success: boolean;
debitNoteNumber: string;
sapDocumentNumber: string;
amount: number;
issueDate: string;
status: string;
creditNoteNumber: string;
message?: string;
}
/** Simulate SAP credit note generation (Form 16). Returns simulated SAP JSON. */
export async function sapSimulateCreditNote(
dealerDetails: SapCreditNoteDealerDetails,
amount: number
): Promise<SapCreditNoteResponse> {
const { data } = await apiClient.post<{ data?: SapCreditNoteResponse } | SapCreditNoteResponse>(
'/form16/sap-simulate/credit-note',
{ dealerDetails, amount }
);
const payload = (data && typeof data === 'object' && 'data' in data ? data.data : data) as SapCreditNoteResponse | undefined;
if (!payload?.creditNoteNumber) throw new Error('Simulated credit note not returned');
return payload;
}
/** Simulate SAP debit note generation (Form 16). Returns simulated SAP JSON. */
export async function sapSimulateDebitNote(params: {
dealerCode: string;
dealerInfo?: SapDebitNoteDealerInfo;
creditNoteNumber: string;
amount: number;
}): Promise<SapDebitNoteResponse> {
const { data } = await apiClient.post<{ data?: SapDebitNoteResponse } | SapDebitNoteResponse>(
'/form16/sap-simulate/debit-note',
params
);
const payload = (data && typeof data === 'object' && 'data' in data ? data.data : data) as SapDebitNoteResponse | undefined;
if (!payload?.debitNoteNumber) throw new Error('Simulated debit note not returned');
return payload;
}
/** Debit note item (from generate-debit-note or credit note detail). */
export interface Form16DebitNoteItem {
id: number;
debitNoteNumber: string | null;
sapDocumentNumber?: string | null;
amount: number | null;
issueDate: string | null;
status: string | null;
reason?: string | null;
}
/** RE only. Generate debit note for a credit note (Form 16). Calls SAP simulation then saves. */
export async function generateForm16DebitNote(
creditNoteId: number,
amount: number
): Promise<{ debitNote: Form16DebitNoteItem; creditNote: Form16CreditNoteItem }> {
const { data } = await apiClient.post<{
data?: { debitNote?: Form16DebitNoteItem; creditNote?: Form16CreditNoteItem };
debitNote?: Form16DebitNoteItem;
creditNote?: Form16CreditNoteItem;
}>(`/form16/credit-notes/${creditNoteId}/generate-debit-note`, { amount });
const payload = data?.data ?? data;
const debitNote = payload?.debitNote;
const creditNote = payload?.creditNote;
if (!debitNote) throw new Error('Debit note not returned');
return { debitNote, creditNote: creditNote! };
}
/** Extracted Form 16A data from OCR (Gemini or regex fallback) */
export interface Form16AExtractedData {
nameAndAddressOfDeductor?: string | null;
deductorName?: string | null;
deductorAddress?: string | null;
deductorPhone?: string | null;
deductorEmail?: string | null;
totalAmountPaid?: number | null;
totalTaxDeducted?: number | null;
totalTdsDeposited?: number | null;
tanOfDeductor?: string | null;
natureOfPayment?: string | null;
transactionDate?: string | null;
statusOfMatchingOltas?: string | null;
dateOfBooking?: string | null;
assessmentYear?: string | null;
quarter?: string | null;
form16aNumber?: string | null;
financialYear?: string | null;
certificateDate?: string | null;
tanNumber?: string | null;
tdsAmount?: number | null;
totalAmount?: number | null;
}
export interface ExtractOcrResponse {
extractedData: Form16AExtractedData;
ocrProvider?: string;
}
/**
* Extract Form 16A data from PDF exact REform16: FormData with 'document', fetchWithAuth (no Content-Type for FormData).
*/
export async function extractOcr(file: File): Promise<ExtractOcrResponse> {
const formData = new FormData();
formData.append('document', file);
const result = await fetchWithAuth('/form16/extract', {
method: 'POST',
body: formData,
});
const raw = result as { data?: { extractedData?: Form16AExtractedData; ocrProvider?: string }; extractedData?: Form16AExtractedData; ocrProvider?: string };
const payload = raw?.data ?? raw;
const extracted = payload?.extractedData;
const ocrProvider = payload?.ocrProvider;
if (!extracted) {
throw new Error((result as { message?: string }).message || 'No extracted data returned');
}
return { extractedData: extracted, ocrProvider };
}
export interface CreateForm16SubmissionPayload {
financialYear: string;
quarter: string;
form16aNumber: string;
tdsAmount: number;
totalAmount: number;
tanNumber: string;
deductorName: string;
version?: number;
file: File;
/** Optional: raw OCR extracted data for audit (stored in form16a_submissions.ocr_extracted_data). */
extractedData?: Form16AExtractedData | null;
}
export interface CreateForm16SubmissionResponse {
requestId: string;
requestNumber: string;
submissionId: number;
/** Set when 26AS matching runs: 'success' | 'failed' | 'resubmission_needed' */
validationStatus?: string;
/** Credit note number when validationStatus === 'success' */
creditNoteNumber?: string | null;
}
/**
* Create Form 16 submission (workflow request + form16a_submissions + document upload).
* Uses fetch + FormData so browser sets Content-Type with boundary (avoids 400).
*/
export async function createForm16Submission(payload: CreateForm16SubmissionPayload): Promise<CreateForm16SubmissionResponse> {
const formData = new FormData();
formData.append('document', payload.file);
formData.append('financialYear', payload.financialYear);
formData.append('quarter', payload.quarter);
formData.append('form16aNumber', payload.form16aNumber);
formData.append('tdsAmount', String(payload.tdsAmount));
formData.append('totalAmount', String(payload.totalAmount));
formData.append('tanNumber', payload.tanNumber);
formData.append('deductorName', payload.deductorName);
if (payload.version != null) formData.append('version', String(payload.version));
if (payload.extractedData != null) {
formData.append('ocrExtractedData', JSON.stringify(payload.extractedData));
}
const body = await fetchWithAuth('/form16/submissions', { method: 'POST', body: formData });
const result = (body as { data?: CreateForm16SubmissionResponse }).data ?? body;
const req = result as CreateForm16SubmissionResponse;
if (!req?.requestNumber) {
throw new Error((body as { message?: string }).message || 'Invalid response from server');
}
return req;
}
// ---------- RE: 26AS ----------
export interface Tds26asEntryItem {
id: number;
tanNumber: string;
deductorName?: string | null;
quarter: string;
assessmentYear?: string | null;
financialYear: string;
sectionCode?: string | null;
amountPaid?: number | null;
taxDeducted: number;
totalTdsDeposited?: number | null;
natureOfPayment?: string | null;
transactionDate?: string | null;
dateOfBooking?: string | null;
statusOltas?: string | null;
remarks?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface List26asParams {
financialYear?: string;
quarter?: string;
tanNumber?: string;
search?: string;
status?: string;
assessmentYear?: string;
sectionCode?: string;
limit?: number;
offset?: number;
}
export interface List26asSummary {
totalRecords: number;
booked: number;
notBooked: number;
pending: number;
totalTaxDeducted: number;
}
export async function list26asEntries(params?: List26asParams): Promise<{
entries: Tds26asEntryItem[];
total: number;
summary: List26asSummary;
}> {
const searchParams = new URLSearchParams();
if (params?.financialYear) searchParams.set('financialYear', params.financialYear);
if (params?.quarter) searchParams.set('quarter', params.quarter);
if (params?.tanNumber) searchParams.set('tanNumber', params.tanNumber);
if (params?.search) searchParams.set('search', params.search);
if (params?.status) searchParams.set('status', params.status);
if (params?.assessmentYear) searchParams.set('assessmentYear', params.assessmentYear);
if (params?.sectionCode) searchParams.set('sectionCode', params.sectionCode);
if (params?.limit != null) searchParams.set('limit', String(params.limit));
if (params?.offset != null) searchParams.set('offset', String(params.offset));
const query = searchParams.toString();
const url = query ? `/form16/26as?${query}` : '/form16/26as';
const { data } = await apiClient.get<{
data?: { entries?: Tds26asEntryItem[]; total?: number; summary?: List26asSummary };
entries?: Tds26asEntryItem[];
total?: number;
summary?: List26asSummary;
}>(url);
const payload = data?.data ?? data;
return {
entries: payload?.entries ?? [],
total: payload?.total ?? 0,
summary: payload?.summary ?? {
totalRecords: 0,
booked: 0,
notBooked: 0,
pending: 0,
totalTaxDeducted: 0,
},
};
}
export async function create26asEntry(body: Partial<Tds26asEntryItem>): Promise<{ entry: Tds26asEntryItem }> {
const { data } = await apiClient.post<{ data?: { entry?: Tds26asEntryItem }; entry?: Tds26asEntryItem }>('/form16/26as', body);
const payload = data?.data ?? data;
const entry = (payload?.entry ?? payload) as Tds26asEntryItem | undefined;
if (!entry || typeof entry !== 'object') throw new Error('No entry returned');
return { entry };
}
export async function update26asEntry(id: number, body: Partial<Tds26asEntryItem>): Promise<{ entry: Tds26asEntryItem }> {
const { data } = await apiClient.put<{ data?: { entry?: Tds26asEntryItem }; entry?: Tds26asEntryItem }>(`/form16/26as/${id}`, body);
const payload = data?.data ?? data;
const entry = (payload?.entry ?? payload) as Tds26asEntryItem | undefined;
if (!entry || typeof entry !== 'object') throw new Error('No entry returned');
return { entry };
}
export async function delete26asEntry(id: number): Promise<void> {
await apiClient.delete(`/form16/26as/${id}`);
}
/** 26AS upload audit log entry (who uploaded, when, records imported). */
export interface Form1626asUploadHistoryItem {
id: number;
uploadedAt: string;
uploadedBy: string;
uploadedByEmail?: string | null;
uploadedByDisplayName?: string | null;
fileName?: string | null;
recordsImported: number;
errorsCount: number;
}
export async function get26asUploadHistory(limit?: number): Promise<Form1626asUploadHistoryItem[]> {
const params = new URLSearchParams();
if (limit != null) params.set('limit', String(limit));
const query = params.toString();
const url = query ? `/form16/26as/upload-history?${query}` : '/form16/26as/upload-history';
const { data } = await apiClient.get<{ data?: { history?: Form1626asUploadHistoryItem[] }; history?: Form1626asUploadHistoryItem[] }>(url);
const payload = data?.data ?? data;
return payload?.history ?? [];
}
/** Timeout for 26AS upload (large files can take 12 min). */
const UPLOAD_26AS_TIMEOUT_MS = 5 * 60 * 1000;
export interface Upload26asResult {
imported: number;
errors: string[];
}
/**
* Upload 26AS TXT file with optional progress callback (0100%).
* Uses XHR so we can report upload progress; after 100% the server may still be processing.
*/
export function upload26asFile(
file: File,
onProgress?: (percent: number) => void
): Promise<Upload26asResult> {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
const url = `${API_BASE_URL}/form16/26as/upload`;
const token = TokenManager.getAccessToken();
const timeoutId = setTimeout(() => {
xhr.abort();
reject(new Error('Upload timed out. Try a smaller file or try again.'));
}, UPLOAD_26AS_TIMEOUT_MS);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && e.total > 0) {
const percent = Math.min(100, Math.round((e.loaded / e.total) * 100));
onProgress?.(percent);
} else {
onProgress?.(0);
}
});
xhr.addEventListener('load', () => {
clearTimeout(timeoutId);
onProgress?.(100);
const contentType = xhr.getResponseHeader('content-type');
const isJson = contentType?.includes('application/json');
const raw = xhr.responseText;
let data: { data?: { imported?: number; errors?: string[] }; message?: string };
try {
data = isJson ? JSON.parse(raw) : { message: raw || 'Request failed' };
} catch {
data = { message: raw || 'Invalid response' };
}
if (xhr.status >= 200 && xhr.status < 300) {
const payload = (data.data ?? data) as { imported?: number; errors?: string[] } | undefined;
resolve({
imported: payload?.imported ?? 0,
errors: Array.isArray(payload?.errors) ? payload.errors : [],
});
} else {
reject(new Error((data as { message?: string }).message || `Request failed ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
clearTimeout(timeoutId);
reject(new Error('Network error during upload'));
});
xhr.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(new Error('Upload was cancelled or timed out'));
});
xhr.open('POST', url);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.withCredentials = true;
xhr.send(formData);
});
}
// ---------- RE: Non-submitted dealers ----------
export interface NotificationHistoryItem {
date: string;
notifiedBy: string;
method: string;
}
export interface NonSubmittedDealerItem {
id: string;
dealerName: string;
dealerCode: string;
email: string;
phone: string;
location: string;
missingQuarters: string[];
lastSubmissionDate: string | null;
daysSinceLastSubmission: number | null;
lastNotifiedDate: string | null;
lastNotifiedBy: string | null;
notificationCount: number;
notificationHistory: NotificationHistoryItem[];
}
export interface NonSubmittedDealersSummary {
totalDealers: number;
nonSubmittedCount: number;
neverSubmittedCount: number;
overdue90Count: number;
}
export interface ListNonSubmittedDealersResponse {
summary: NonSubmittedDealersSummary;
dealers: NonSubmittedDealerItem[];
}
export async function listNonSubmittedDealers(financialYear?: string): Promise<ListNonSubmittedDealersResponse> {
const params = new URLSearchParams();
if (financialYear) params.set('financialYear', financialYear);
const query = params.toString();
const url = query ? `/form16/non-submitted-dealers?${query}` : '/form16/non-submitted-dealers';
const { data } = await apiClient.get<{ data?: ListNonSubmittedDealersResponse }>(url);
const payload = (data?.data ?? data) as ListNonSubmittedDealersResponse | undefined;
const s = payload?.summary;
return {
summary: s ?? {
totalDealers: 0,
nonSubmittedCount: 0,
neverSubmittedCount: 0,
overdue90Count: 0,
},
dealers: payload?.dealers ?? [],
};
}
/** Send "submit Form 16" notification to one non-submitted dealer. Returns updated dealer (with lastNotifiedDate set). */
export async function notifyNonSubmittedDealer(dealerCode: string, financialYear?: string): Promise<NonSubmittedDealerItem> {
const { data } = await apiClient.post<{ data?: { dealer: NonSubmittedDealerItem } }>('/form16/non-submitted-dealers/notify', {
dealerCode,
financialYear: financialYear || undefined,
});
const dealer = data?.data?.dealer;
if (!dealer) throw new Error('No dealer returned');
return dealer;
}
// ---------- Dealer: Pending Submissions page ----------
export interface DealerPendingSubmissionItem {
id: number;
requestId: string;
form16a_number: string;
financial_year: string;
quarter: string;
version: number;
version_number?: number;
status: string;
/** Display label: Completed | Resubmission needed | Duplicate submission | Failed | Under review (never Pending) */
display_status?: string;
validation_status: string | null;
submitted_date: string | null;
total_amount?: number | null;
credit_note_number?: string | null;
document_url?: string | null;
}
export interface DealerPendingQuarterItem {
financial_year: string;
quarter: string;
dealer_name?: string | null;
has_submission: boolean;
latest_submission_status: string | null;
latest_submission_id: number | null;
audit_start_date: string | null;
twenty_six_as_start_date?: string | null;
days_remaining: number | null;
days_overdue: number | null;
days_since_26as_uploaded?: number | null;
}
/** List dealer's Form 16 submissions (pending/failed). Dealer only. */
export async function listDealerSubmissions(params?: {
status?: string;
financialYear?: string;
quarter?: string;
}): Promise<DealerPendingSubmissionItem[]> {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.financialYear) searchParams.set('financialYear', params.financialYear);
if (params?.quarter) searchParams.set('quarter', params.quarter);
const query = searchParams.toString();
const url = query ? `/form16/dealer/submissions?${query}` : '/form16/dealer/submissions';
const { data } = await apiClient.get<{ data?: DealerPendingSubmissionItem[] }>(url);
const payload = data?.data ?? data;
return Array.isArray(payload) ? payload : [];
}
/** List dealer's pending quarters (no completed Form 16A). Dealer only. */
export async function listDealerPendingQuarters(): Promise<DealerPendingQuarterItem[]> {
const { data } = await apiClient.get<{ data?: DealerPendingQuarterItem[] }>('/form16/dealer/pending-quarters');
const payload = data?.data ?? data;
return Array.isArray(payload) ? payload : [];
}

View File

@ -158,41 +158,8 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
return { id: data?.requestId } as any; return { id: data?.requestId } as any;
} }
export async function listWorkflows(params: { export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
page?: number; const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
limit?: number;
search?: string;
status?: string;
priority?: string;
templateType?: string;
financialYear?: string;
quarter?: string;
department?: string;
initiator?: string;
approver?: string;
slaCompliance?: string;
dateRange?: string;
startDate?: string;
endDate?: string;
} = {}) {
const {
page = 1,
limit = 20,
search,
status,
priority,
templateType,
financialYear,
quarter,
department,
initiator,
approver,
slaCompliance,
dateRange,
startDate,
endDate,
} = params;
const res = await apiClient.get('/workflows', { const res = await apiClient.get('/workflows', {
params: { params: {
page, page,
@ -201,59 +168,22 @@ export async function listWorkflows(params: {
status, status,
priority, priority,
templateType, templateType,
financialYear,
quarter,
department, department,
initiator, initiator,
approver, approver,
slaCompliance, slaCompliance,
dateRange, dateRange,
startDate, startDate,
endDate, endDate
}, }
}); });
return res.data?.data || res.data; return res.data?.data || res.data;
} }
// List requests where user is a participant (not initiator) - for regular users' "All Requests" page // List requests where user is a participant (not initiator) - for regular users' "All Requests" page
// SEPARATE from listWorkflows (admin) to avoid interference // SEPARATE from listWorkflows (admin) to avoid interference
export async function listParticipantRequests(params: { export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
page?: number; const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate } = params;
limit?: number;
search?: string;
status?: string;
priority?: string;
templateType?: string;
financialYear?: string;
quarter?: string;
department?: string;
initiator?: string;
approver?: string;
approverType?: string;
slaCompliance?: string;
dateRange?: string;
startDate?: string;
endDate?: string;
} = {}) {
const {
page = 1,
limit = 20,
search,
status,
priority,
templateType,
financialYear,
quarter,
department,
initiator,
approver,
approverType,
slaCompliance,
dateRange,
startDate,
endDate,
} = params;
const res = await apiClient.get('/workflows/participant-requests', { const res = await apiClient.get('/workflows/participant-requests', {
params: { params: {
page, page,
@ -262,8 +192,6 @@ export async function listParticipantRequests(params: {
status, status,
priority, priority,
templateType, templateType,
financialYear,
quarter,
department, department,
initiator, initiator,
approver, approver,
@ -271,14 +199,13 @@ export async function listParticipantRequests(params: {
slaCompliance, slaCompliance,
dateRange, dateRange,
startDate, startDate,
endDate, endDate
}, }
}); });
// Response structure: { success, data: { data: [...], pagination: {...} } } // Response structure: { success, data: { data: [...], pagination: {...} } }
return { return {
data: res.data?.data?.data || res.data?.data || [], data: res.data?.data?.data || res.data?.data || [],
pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }, pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }
}; };
} }
@ -334,9 +261,9 @@ export async function listMyInitiatedWorkflows(params: { page?: number; limit?:
}; };
} }
export async function listOpenForMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string } = {}) { export async function listOpenForMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, financialYear, quarter, sortBy, sortOrder } = params; const { page = 1, limit = 20, search, status, priority, templateType, sortBy, sortOrder } = params;
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit, search, status, priority, templateType, financialYear, quarter, sortBy, sortOrder } }); const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit, search, status, priority, templateType, sortBy, sortOrder } });
// Response structure: { success, data: { data: [...], pagination: {...} } } // Response structure: { success, data: { data: [...], pagination: {...} } }
return { return {
data: res.data?.data?.data || res.data?.data || [], data: res.data?.data?.data || res.data?.data || [],
@ -344,9 +271,9 @@ export async function listOpenForMe(params: { page?: number; limit?: number; sea
}; };
} }
export async function listClosedByMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string } = {}) { export async function listClosedByMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, financialYear, quarter, sortBy, sortOrder } = params; const { page = 1, limit = 20, search, status, priority, templateType, sortBy, sortOrder } = params;
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit, search, status, priority, templateType, financialYear, quarter, sortBy, sortOrder } }); const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit, search, status, priority, templateType, sortBy, sortOrder } });
// Response structure: { success, data: { data: [...], pagination: {...} } } // Response structure: { success, data: { data: [...], pagination: {...} } }
return { return {
data: res.data?.data?.data || res.data?.data || [], data: res.data?.data?.data || res.data?.data || [],

View File

@ -65,63 +65,47 @@ export const getPriorityConfig = (priority: string) => {
* @param status - Status string from backend * @param status - Status string from backend
* @returns Configuration object with Tailwind CSS classes * @returns Configuration object with Tailwind CSS classes
*/ */
export const getStatusConfig = (status: string): { color: string; label: string; icon: typeof Clock; iconColor: string } => { export const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'pending': case 'pending':
return { return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200', color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
label: 'pending', label: 'pending'
icon: Clock,
iconColor: 'text-yellow-600'
}; };
case 'paused': case 'paused':
return { return {
color: 'bg-gray-400 text-gray-100 border-gray-500', color: 'bg-gray-400 text-gray-100 border-gray-500',
label: 'paused', label: 'paused'
icon: AlertTriangle,
iconColor: 'text-gray-600'
}; };
case 'in-review': case 'in-review':
return { return {
color: 'bg-blue-100 text-blue-800 border-blue-200', color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'in-review', label: 'in-review'
icon: RefreshCw,
iconColor: 'text-blue-600'
}; };
case 'approved': case 'approved':
return { return {
color: 'bg-green-100 text-green-800 border-green-200', color: 'bg-green-100 text-green-800 border-green-200',
label: 'approved', label: 'approved'
icon: CheckCircle,
iconColor: 'text-green-600'
}; };
case 'rejected': case 'rejected':
return { return {
color: 'bg-red-100 text-red-800 border-red-200', color: 'bg-red-100 text-red-800 border-red-200',
label: 'rejected', label: 'rejected'
icon: XCircle,
iconColor: 'text-red-600'
}; };
case 'closed': case 'closed':
return { return {
color: 'bg-gray-100 text-gray-800 border-gray-300', color: 'bg-gray-100 text-gray-800 border-gray-300',
label: 'closed', label: 'closed'
icon: CheckCircle,
iconColor: 'text-gray-600'
}; };
case 'skipped': case 'skipped':
return { return {
color: 'bg-orange-100 text-orange-800 border-orange-200', color: 'bg-orange-100 text-orange-800 border-orange-200',
label: 'skipped', label: 'skipped'
icon: Activity,
iconColor: 'text-orange-600'
}; };
default: default:
return { return {
color: 'bg-gray-100 text-gray-800 border-gray-200', color: 'bg-gray-100 text-gray-800 border-gray-200',
label: status, label: status
icon: AlertTriangle,
iconColor: 'text-gray-600'
}; };
} }
}; };