form 16 code is added to the flow
This commit is contained in:
parent
4d0079c7fc
commit
a4662c99f3
@ -3,3 +3,7 @@ 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
741
README.md
@ -1,716 +1,93 @@
|
|||||||
# 🏍️ Royal Enfield Approval Portal
|
# RE-Workflow-FE
|
||||||
|
|
||||||
A modern, enterprise-grade approval and request management system built with React, TypeScript, and Tailwind CSS.
|
|
||||||
|
|
||||||
## 📋 Table of Contents
|
|
||||||
|
|
||||||
- [Features](#-features)
|
|
||||||
- [Tech Stack](#️-tech-stack)
|
|
||||||
- [Prerequisites](#-prerequisites)
|
|
||||||
- [Installation](#-installation)
|
|
||||||
- [Development](#-development)
|
|
||||||
- [Project Structure](#-project-structure)
|
|
||||||
- [Available Scripts](#-available-scripts)
|
|
||||||
- [Configuration](#️-configuration)
|
|
||||||
- [Key Features Deep Dive](#-key-features-deep-dive)
|
|
||||||
- [Troubleshooting](#-troubleshooting)
|
|
||||||
- [Contributing](#-contributing)
|
|
||||||
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
### 🔄 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
|
|
||||||
|
|
||||||
### 📊 Comprehensive Dashboard
|
|
||||||
- Real-time statistics and metrics
|
|
||||||
- High-priority alerts and critical request tracking
|
|
||||||
- Recent activity feed with pagination
|
|
||||||
- Upcoming deadlines and SLA breach warnings
|
|
||||||
- Department-wise performance metrics
|
|
||||||
- Customizable KPI widgets (Admin only)
|
|
||||||
|
|
||||||
### 🎯 Request Management
|
|
||||||
- Create, track, and manage approval requests
|
|
||||||
- Document upload and management with file type validation
|
|
||||||
- Work notes and comprehensive audit trails
|
|
||||||
- Spectator and stakeholder management
|
|
||||||
- Request filtering, search, and export capabilities
|
|
||||||
- Detailed request lifecycle tracking
|
|
||||||
|
|
||||||
### 👥 Admin Control Panel
|
|
||||||
- **User Management** - Search, assign roles (USER, MANAGEMENT, ADMIN), and manage user permissions
|
|
||||||
- **User Role Management** - Assign and manage user roles with Okta integration
|
|
||||||
- **System Configuration** - Comprehensive admin settings:
|
|
||||||
- **KPI Configuration** - Configure dashboard KPIs, visibility, and thresholds
|
|
||||||
- **Analytics Configuration** - Data retention, export formats, and analytics features
|
|
||||||
- **TAT Configuration** - Working hours, priority-based TAT, escalation settings
|
|
||||||
- **Notification Configuration** - Email templates, notification channels, and settings
|
|
||||||
- **Notification Preferences** - User-configurable notification settings
|
|
||||||
- **Document Configuration** - File type restrictions, size limits, upload policies
|
|
||||||
- **Dashboard Configuration** - Customize dashboard layout and widgets per role
|
|
||||||
- **AI Configuration** - AI provider settings, parameters, and features
|
|
||||||
- **Sharing Configuration** - Sharing policies and permissions
|
|
||||||
- **Holiday Management** - Configure business holidays for SLA calculations
|
|
||||||
|
|
||||||
### 📈 Approver Performance Analytics
|
|
||||||
- Detailed approver performance metrics and statistics
|
|
||||||
- Request approval history and trends
|
|
||||||
- Average approval time analysis
|
|
||||||
- Approval rate and efficiency metrics
|
|
||||||
- TAT compliance tracking per approver
|
|
||||||
- Performance comparison and benchmarking
|
|
||||||
- Export capabilities for performance reports
|
|
||||||
|
|
||||||
### 💬 Real-Time Live Chat (Work Notes)
|
|
||||||
- **WebSocket Integration** - Real-time bidirectional communication
|
|
||||||
- **Live Work Notes** - Instant messaging within request context
|
|
||||||
- **Presence Indicators** - See who's online/offline in real-time
|
|
||||||
- **Mention System** - @mention participants for notifications
|
|
||||||
- **File Attachments** - Share documents directly in chat
|
|
||||||
- **Message History** - Persistent chat history per request
|
|
||||||
- **Auto-reconnection** - Automatic reconnection on network issues
|
|
||||||
- **Room-based Communication** - Isolated chat rooms per request
|
|
||||||
|
|
||||||
### 🔔 Advanced Notifications
|
|
||||||
- **Web Push Notifications** - Browser push notifications using VAPID
|
|
||||||
- **Service Worker Integration** - Background notification delivery
|
|
||||||
- **Real-time Toast Notifications** - In-app notification system
|
|
||||||
- **SLA Tracking & Reminders** - Automated TAT breach alerts
|
|
||||||
- **Approval Status Updates** - Real-time status change notifications
|
|
||||||
- **Email Notifications** - Configurable email notification channels
|
|
||||||
- **Notification Preferences** - User-configurable notification settings
|
|
||||||
|
|
||||||
### 🎨 Modern UI/UX
|
|
||||||
- Responsive design (mobile, tablet, desktop)
|
|
||||||
- Dark mode support
|
|
||||||
- Accessible components (WCAG compliant)
|
|
||||||
- Royal Enfield brand theming
|
|
||||||
- Smooth animations and transitions
|
|
||||||
- Intuitive navigation and user flows
|
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
## 📦 Prerequisites
|
|
||||||
|
|
||||||
- **Node.js:** >= 18.0.0
|
|
||||||
- **npm:** >= 9.0.0 (or yarn/pnpm)
|
|
||||||
- **Git:** Latest version
|
|
||||||
|
|
||||||
## 🚀 Installation
|
|
||||||
|
|
||||||
### Quick Start Checklist
|
|
||||||
|
|
||||||
- [ ] Clone the repository
|
|
||||||
- [ ] Install Node.js (>= 18.0.0) and npm (>= 9.0.0)
|
|
||||||
- [ ] Install project dependencies
|
|
||||||
- [ ] Set up environment variables (`.env.local`)
|
|
||||||
- [ ] Ensure backend API is running (optional for initial setup)
|
|
||||||
- [ ] Start development server
|
|
||||||
|
|
||||||
### 1. Clone the repository
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd Re_Frontend_Code
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 2. Install dependencies
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
npm install
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 3. Set up environment variables
|
|
||||||
|
|
||||||
#### Option A: Automated Setup (Recommended - Unix/Linux/Mac)
|
|
||||||
|
|
||||||
Run the setup script to automatically create environment files:
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
chmod +x setup-env.sh
|
|
||||||
./setup-env.sh
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
This script will:
|
|
||||||
- Create `.env.example` with all required variables
|
|
||||||
- Create `.env.local` for local development
|
|
||||||
- Create `.env.production` with your production configuration (interactive)
|
|
||||||
|
|
||||||
#### Option B: Manual Setup (Windows or Custom Configuration)
|
|
||||||
|
|
||||||
**For Windows (PowerShell):**
|
|
||||||
|
|
||||||
1. Create `.env.local` file in the project root:
|
|
||||||
|
|
||||||
\`\`\`powershell
|
## Getting started
|
||||||
# Create .env.local file
|
|
||||||
New-Item -Path .env.local -ItemType File
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
2. Add the following content to `.env.local`:
|
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||||
|
|
||||||
\`\`\`env
|
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)!
|
||||||
# Local Development Environment
|
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api/v1
|
|
||||||
VITE_BASE_URL=http://localhost:5000
|
|
||||||
|
|
||||||
# Okta Authentication Configuration
|
## Add your files
|
||||||
VITE_OKTA_DOMAIN=your-okta-domain.okta.com
|
|
||||||
VITE_OKTA_CLIENT_ID=your-okta-client-id
|
|
||||||
|
|
||||||
# Push Notifications (Web Push / VAPID)
|
- [ ] [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
|
||||||
VITE_PUBLIC_VAPID_KEY=your-vapid-public-key
|
- [ ] [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:
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For Production:**
|
```
|
||||||
|
cd existing_repo
|
||||||
|
git remote add origin http://10.10.1.3:2010/pradeep.jha/re-workflow-fe.git
|
||||||
|
git branch -M main
|
||||||
|
git push -uf origin main
|
||||||
|
```
|
||||||
|
|
||||||
Create `.env.production` with production values:
|
## Integrate with your tools
|
||||||
|
|
||||||
\`\`\`env
|
- [ ] [Set up project integrations](http://10.10.1.3:2010/pradeep.jha/re-workflow-fe/-/settings/integrations)
|
||||||
# Production Environment
|
|
||||||
VITE_API_BASE_URL=https://your-backend-url.com/api/v1
|
|
||||||
VITE_BASE_URL=https://your-backend-url.com
|
|
||||||
|
|
||||||
# Okta Authentication Configuration
|
## Collaborate with your team
|
||||||
VITE_OKTA_DOMAIN=https://your-org.okta.com
|
|
||||||
VITE_OKTA_CLIENT_ID=your-production-client-id
|
|
||||||
|
|
||||||
# Push Notifications (Web Push / VAPID)
|
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
||||||
VITE_PUBLIC_VAPID_KEY=your-production-vapid-key
|
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
||||||
\`\`\`
|
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
||||||
|
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
||||||
|
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
|
||||||
|
|
||||||
#### Environment Variables Reference
|
## Test and Deploy
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
Use the built-in continuous integration in GitLab.
|
||||||
|----------|-------------|----------|---------|
|
|
||||||
| `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:**
|
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
|
||||||
- `VITE_BASE_URL` is used for WebSocket connections and must point to the base backend URL (not `/api/v1`)
|
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
||||||
- `VITE_PUBLIC_VAPID_KEY` is required for web push notifications. Generate using:
|
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
||||||
\`\`\`bash
|
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
||||||
npm install -g web-push
|
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
||||||
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
|
# Editing this README
|
||||||
# Option 1: Kill the process using the port
|
|
||||||
# Windows
|
|
||||||
netstat -ano | findstr :5173
|
|
||||||
taskkill /PID <PID> /F
|
|
||||||
|
|
||||||
# Unix/Linux/Mac
|
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.
|
||||||
lsof -ti:5173 | xargs kill -9
|
|
||||||
|
|
||||||
# Option 2: Use a different port
|
## Suggestions for a good README
|
||||||
npm run dev -- --port 3000
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
#### Environment Variables Not Loading
|
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. Ensure variables are prefixed with `VITE_`
|
## Name
|
||||||
2. Restart the dev server after changing `.env` files
|
Choose a self-explaining name for your project.
|
||||||
3. Check that `.env.local` exists in the project root
|
|
||||||
4. Verify no typos in variable names
|
|
||||||
|
|
||||||
#### Backend Connection Issues
|
## Description
|
||||||
|
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.
|
||||||
|
|
||||||
1. Verify backend is running on the configured port
|
## Badges
|
||||||
2. Check `VITE_API_BASE_URL` in `.env.local` matches backend URL
|
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.
|
||||||
3. Ensure CORS is configured in backend to allow frontend origin
|
|
||||||
4. Check browser console for detailed error messages
|
|
||||||
|
|
||||||
#### Build Errors
|
## Visuals
|
||||||
|
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.
|
||||||
|
|
||||||
\`\`\`bash
|
## Installation
|
||||||
# Clear cache and rebuild
|
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.
|
||||||
rm -rf node_modules/.vite
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Check for TypeScript errors
|
## Usage
|
||||||
npm run type-check
|
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.
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Getting Help
|
## Support
|
||||||
|
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.
|
||||||
|
|
||||||
- Check browser console for errors
|
## Roadmap
|
||||||
- Verify all environment variables are set correctly
|
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||||
- 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
|
## Contributing
|
||||||
|
State if you are open to contributions and what your requirements are for accepting them.
|
||||||
|
|
||||||
The application supports three user roles with different access levels:
|
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.
|
||||||
|
|
||||||
### USER Role
|
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.
|
||||||
- Create and manage own requests
|
|
||||||
- View assigned requests
|
|
||||||
- Approve/reject requests assigned to them
|
|
||||||
- Add work notes and comments
|
|
||||||
- Upload documents
|
|
||||||
|
|
||||||
### MANAGEMENT Role
|
## Authors and acknowledgment
|
||||||
- All USER permissions
|
Show your appreciation to those who have contributed to the project.
|
||||||
- View all requests across the organization
|
|
||||||
- Access to detailed reports and analytics
|
|
||||||
- Approver Performance dashboard access
|
|
||||||
- Export capabilities
|
|
||||||
|
|
||||||
### ADMIN Role
|
## License
|
||||||
- All MANAGEMENT permissions
|
For open source projects, say how it is licensed.
|
||||||
- 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.
|
||||||
|
|||||||
105
docs/Form16_SUBMISSION_STATUS_AND_FEATURES.md
Normal file
105
docs/Form16_SUBMISSION_STATUS_AND_FEATURES.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# 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
12
package-lock.json
generated
@ -73,6 +73,7 @@
|
|||||||
"@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",
|
||||||
@ -3598,13 +3599,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.19",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
"integrity": "sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==",
|
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
|
|||||||
@ -78,6 +78,7 @@
|
|||||||
"@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",
|
||||||
|
|||||||
79
src/App.tsx
79
src/App.tsx
@ -25,6 +25,14 @@ 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';
|
||||||
@ -445,11 +453,12 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
{/* Admin Routes Group with Shared Layout */}
|
{/* Admin Routes Group with Shared Layout */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="admin" 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 />} />
|
||||||
@ -505,6 +514,74 @@ 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"
|
||||||
|
|||||||
585
src/components/admin/Form16AdminConfig/Form16AdminConfig.tsx
Normal file
585
src/components/admin/Form16AdminConfig/Form16AdminConfig.tsx
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
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 haven’t 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 haven’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 } from 'lucide-react';
|
import { Bell, Settings, User, Plus, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List, Share2, ChevronDown, ChevronRight, Receipt, Shield } 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,6 +17,7 @@ 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';
|
||||||
@ -31,10 +32,12 @@ 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
|
||||||
@ -48,6 +51,9 @@ 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 {
|
||||||
@ -87,8 +93,41 @@ 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]);
|
}, [isDealer, isAdmin]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
@ -253,15 +292,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.id === 'admin/templates') {
|
|
||||||
onNavigate?.('admin/templates');
|
|
||||||
} else {
|
|
||||||
onNavigate?.(item.id);
|
onNavigate?.(item.id);
|
||||||
}
|
if (window.innerWidth < 768) setSidebarOpen(false);
|
||||||
// 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'
|
||||||
@ -272,6 +304,138 @@ 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 */}
|
||||||
|
|||||||
@ -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 } from '../services/authApi';
|
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi, passwordLogin } from '../services/authApi';
|
||||||
import { tanflowLogout } from '../services/tanflowAuth';
|
import { tanflowLogout } from '../services/tanflowAuth';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@ -33,6 +33,8 @@ 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>;
|
||||||
@ -100,31 +102,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);
|
||||||
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
|
const hasOAuthCode = urlParams.has('code');
|
||||||
|
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);
|
||||||
// Clean URL but preserve logout flags if they exist (for prompt=login)
|
// Persist "force re-auth" so that when user clicks "RE Employee Login" we add prompt=login
|
||||||
const cleanParams = new URLSearchParams();
|
// (URL is cleaned below, so we'd otherwise lose the fact we just logged out)
|
||||||
if (urlParams.has('okta_logged_out')) {
|
try {
|
||||||
cleanParams.set('okta_logged_out', 'true');
|
sessionStorage.setItem('__force_reauth_after_logout__', 'true');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not set force reauth flag:', e);
|
||||||
}
|
}
|
||||||
if (urlParams.has('tanflow_logged_out')) {
|
// Use a fully clean URL so next load (e.g. after Okta login) does not see logout params and clear again
|
||||||
cleanParams.set('tanflow_logged_out', 'true');
|
window.history.replaceState({}, document.title, '/');
|
||||||
}
|
|
||||||
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
|
|
||||||
window.history.replaceState({}, document.title, newUrl);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,6 +223,13 @@ 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')) {
|
||||||
@ -237,7 +246,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as processed immediately to prevent duplicate calls
|
// Mark as processed immediately to prevent duplicate calls (OKTA only)
|
||||||
callbackProcessedRef.current = true;
|
callbackProcessedRef.current = true;
|
||||||
|
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
@ -246,17 +255,6 @@ 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}`));
|
||||||
@ -465,8 +463,11 @@ 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 isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
|
const hasLogoutInUrl = 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}&` +
|
||||||
@ -476,9 +477,10 @@ 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 even if a session still exists
|
// This ensures Okta requires login (password) even if an SSO 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;
|
||||||
@ -488,6 +490,24 @@ 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
|
||||||
@ -656,6 +676,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
user,
|
user,
|
||||||
error,
|
error,
|
||||||
login,
|
login,
|
||||||
|
loginWithPassword,
|
||||||
logout,
|
logout,
|
||||||
getAccessTokenSilently,
|
getAccessTokenSilently,
|
||||||
refreshTokenSilently,
|
refreshTokenSilently,
|
||||||
@ -724,6 +745,7 @@ 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,
|
||||||
|
|||||||
@ -4,11 +4,31 @@ 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;
|
||||||
@ -16,6 +36,8 @@ 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;
|
||||||
@ -32,6 +54,8 @@ export function StandardClosedRequestsFilters({
|
|||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
templateTypeFilter,
|
templateTypeFilter,
|
||||||
|
form16FinancialYear = '',
|
||||||
|
form16Quarter = '',
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
@ -39,10 +63,14 @@ 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">
|
||||||
@ -133,15 +161,41 @@ 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="All Templates" />
|
<SelectValue placeholder="Request type" />
|
||||||
</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">
|
||||||
|
|||||||
@ -4,17 +4,39 @@ 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;
|
||||||
@ -32,17 +54,23 @@ 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">
|
||||||
@ -122,15 +150,41 @@ 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="All Templates" />
|
<SelectValue placeholder="Request type" />
|
||||||
</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">
|
||||||
|
|||||||
@ -20,6 +20,15 @@ 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;
|
||||||
@ -101,6 +110,11 @@ export function StandardUserAllRequestsFilters({
|
|||||||
loadingDepartments,
|
loadingDepartments,
|
||||||
initiatorSearch,
|
initiatorSearch,
|
||||||
approverSearch,
|
approverSearch,
|
||||||
|
showForm16Filter = false,
|
||||||
|
form16FinancialYear = 'all',
|
||||||
|
form16Quarter = 'all',
|
||||||
|
onForm16FinancialYearChange,
|
||||||
|
onForm16QuarterChange,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
@ -188,9 +202,38 @@ 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}
|
||||||
|
|||||||
300
src/custom/components/request-detail/Form16OverviewTab.tsx
Normal file
300
src/custom/components/request-detail/Form16OverviewTab.tsx
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
src/custom/components/request-detail/Form16QuickActions.tsx
Normal file
205
src/custom/components/request-detail/Form16QuickActions.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/custom/components/request-detail/Form16WorkflowTab.tsx
Normal file
125
src/custom/components/request-detail/Form16WorkflowTab.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -16,6 +16,8 @@
|
|||||||
// 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';
|
||||||
|
|||||||
@ -39,9 +39,13 @@ 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';
|
||||||
@ -281,7 +285,12 @@ 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';
|
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
|
||||||
|
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(() => {
|
||||||
@ -409,10 +418,17 @@ 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}
|
||||||
@ -436,7 +452,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">Overview</span>
|
<span className="truncate">{isForm16Request ? 'Form 16' : 'Overview'}</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isClosed && summaryDetails && (
|
{isClosed && summaryDetails && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@ -472,6 +488,7 @@ 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"
|
||||||
@ -488,13 +505,14 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Layout */}
|
{/* Main Layout */}
|
||||||
<div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
|
<div className={activeTab === 'worknotes' && !isForm16Request ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
|
||||||
{/* Left Column: Tab content */}
|
{/* Left Column: Tab content */}
|
||||||
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
|
<div className={activeTab === 'worknotes' && !isForm16Request ? '' : '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}
|
||||||
@ -517,6 +535,28 @@ 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 && (
|
||||||
@ -531,6 +571,14 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<TabsContent value="workflow" className="mt-0">
|
<TabsContent value="workflow" className="mt-0">
|
||||||
|
{isForm16Request ? (
|
||||||
|
<Form16WorkflowTab
|
||||||
|
request={request}
|
||||||
|
requestId={apiRequest?.requestId || requestIdentifier}
|
||||||
|
isReUser={isReUser}
|
||||||
|
onRefresh={refreshDetails}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<CustomWorkflowTab
|
<CustomWorkflowTab
|
||||||
request={request}
|
request={request}
|
||||||
user={user}
|
user={user}
|
||||||
@ -545,6 +593,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
}}
|
}}
|
||||||
onRefresh={refreshDetails}
|
onRefresh={refreshDetails}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="documents" className="mt-0">
|
<TabsContent value="documents" className="mt-0">
|
||||||
@ -563,6 +612,7 @@ 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}
|
||||||
@ -577,10 +627,20 @@ 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 */}
|
{/* Right Column: Quick Actions Sidebar – RE users / employees / admins only; hidden for dealers */}
|
||||||
{activeTab !== 'worknotes' && (
|
{!isDealer && activeTab !== 'worknotes' && (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
{/* Form 16 RE actions – Quick Actions section (Form 16 only, no change to shared workflow) */}
|
||||||
|
{isForm16Request && isReUser && (
|
||||||
|
<Form16QuickActions
|
||||||
|
requestId={apiRequest?.requestId || requestIdentifier}
|
||||||
|
request={request}
|
||||||
|
onRefresh={refreshDetails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<QuickActionsSidebar
|
<QuickActionsSidebar
|
||||||
request={request}
|
request={request}
|
||||||
isInitiator={isInitiator}
|
isInitiator={isInitiator}
|
||||||
@ -599,6 +659,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
currentUserId={(user as any)?.userId}
|
currentUserId={(user as any)?.userId}
|
||||||
apiRequest={apiRequest}
|
apiRequest={apiRequest}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { TrendingUp, Clock, CheckCircle, CheckCircle2, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw, Eye, FileSpreadsheet, X, Loader2 } from 'lucide-react';
|
import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw, Eye, FileSpreadsheet, X, Loader2 } from 'lucide-react';
|
||||||
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||||
import {
|
import {
|
||||||
@ -26,7 +26,7 @@ import {
|
|||||||
// InitiatorActionModal - Removed, using direct buttons instead
|
// InitiatorActionModal - Removed, using direct buttons instead
|
||||||
} from './modals';
|
} from './modals';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice, sendCreditNoteToDealer, retriggerWFMPush } from '@/services/dealerClaimApi';
|
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice, sendCreditNoteToDealer } from '@/services/dealerClaimApi';
|
||||||
import { getWorkflowDetails, approveLevel, rejectLevel, handleInitiatorAction, getWorkflowHistory } from '@/services/workflowApi';
|
import { getWorkflowDetails, approveLevel, rejectLevel, handleInitiatorAction, getWorkflowHistory } from '@/services/workflowApi';
|
||||||
import { uploadDocument } from '@/services/documentApi';
|
import { uploadDocument } from '@/services/documentApi';
|
||||||
import { TokenManager } from '@/utils/tokenManager';
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
@ -1766,88 +1766,19 @@ export function DealerClaimWorkflowTab({
|
|||||||
})()}
|
})()}
|
||||||
{/* CSV Export Button (Requestor Claim Approval) */}
|
{/* CSV Export Button (Requestor Claim Approval) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const isInvoiceStep = (step.levelName || step.title || '').toLowerCase().includes('requestor claim') ||
|
const isRequestorClaimStep = (step.levelName || step.title || '').toLowerCase().includes('requestor claim') ||
|
||||||
(step.levelName || step.title || '').toLowerCase().includes('requestor - claim') ||
|
(step.levelName || step.title || '').toLowerCase().includes('requestor - claim');
|
||||||
(step.levelName || step.title || '').toLowerCase().includes('e-invoice') ||
|
const hasInvoice = request?.invoice || (request?.irn && step.status === 'approved');
|
||||||
(step.levelName || step.title || '').toLowerCase().includes('invoice generation');
|
return isRequestorClaimStep && hasInvoice && (
|
||||||
|
|
||||||
const invoice = request?.invoice || request?.claimDetails?.invoice;
|
|
||||||
const hasInvoice = !!invoice || (request?.irn && step.status === 'approved');
|
|
||||||
if (!isInvoiceStep || !hasInvoice) return null;
|
|
||||||
|
|
||||||
const wfmStatus = invoice?.wfmPushStatus;
|
|
||||||
const wfmError = invoice?.wfmPushError;
|
|
||||||
|
|
||||||
const handleRetrigger = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
toast.loading('Retriggering WFM push...');
|
|
||||||
await retriggerWFMPush(request.id || request.requestId);
|
|
||||||
toast.dismiss();
|
|
||||||
toast.success('WFM push re-triggered successfully');
|
|
||||||
// Optionally refresh data
|
|
||||||
if (typeof (request as any).refresh === 'function') {
|
|
||||||
(request as any).refresh();
|
|
||||||
} else {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.dismiss();
|
|
||||||
toast.error('Failed to re-trigger WFM push');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 ml-1">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 hover:bg-emerald-100"
|
className="h-6 w-6 p-0 hover:bg-emerald-100 ml-1"
|
||||||
title="Export CSV"
|
title="Export CSV"
|
||||||
onClick={handleDownloadCSV}
|
onClick={handleDownloadCSV}
|
||||||
>
|
>
|
||||||
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-600" />
|
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* WFM Push Status Icon */}
|
|
||||||
{wfmStatus === 'SUCCESS' && (
|
|
||||||
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-green-50" title="Pushed to WFM successfully">
|
|
||||||
<CheckCircle2 className="w-3.5 h-3.5 text-green-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{wfmStatus === 'FAILED' && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-red-50" title={`WFM Push Failed: ${wfmError || 'Unknown error'}`}>
|
|
||||||
<XCircle className="w-3.5 h-3.5 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-[10px] text-red-600 border-red-200 hover:bg-red-50"
|
|
||||||
onClick={handleRetrigger}
|
|
||||||
>
|
|
||||||
Retry Push
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(wfmStatus === 'PENDING' || !wfmStatus) && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-gray-50" title="WFM Push Pending">
|
|
||||||
<Clock className="w-3.5 h-3.5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-[10px] text-blue-600 border-blue-200 hover:bg-blue-50"
|
|
||||||
onClick={handleRetrigger}
|
|
||||||
>
|
|
||||||
Push Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -331,9 +331,11 @@ export function useRequestDetails(
|
|||||||
internalOrders: internalOrders || [],
|
internalOrders: internalOrders || [],
|
||||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
// New normalized tables (also available via claimDetails for backward compatibility)
|
||||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
||||||
invoice: claimDetails?.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);
|
||||||
@ -599,9 +601,11 @@ export function useRequestDetails(
|
|||||||
internalOrders: internalOrders || [],
|
internalOrders: internalOrders || [],
|
||||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
// New normalized tables (also available via claimDetails for backward compatibility)
|
||||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
||||||
invoice: claimDetails?.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);
|
||||||
|
|||||||
@ -30,6 +30,8 @@ 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;
|
||||||
@ -290,7 +292,7 @@ export function Admin() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<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" />
|
||||||
@ -300,15 +302,24 @@ 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">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
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">
|
<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" />
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
Administrator: {user?.displayName || 'Admin User'}
|
Administrator: {user?.displayName || 'Admin User'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Main 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">
|
||||||
|
|||||||
@ -2,150 +2,138 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { LogIn, Shield } from 'lucide-react';
|
import { ArrowRight, 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 = () => {
|
img.onload = () => setImageLoaded(true);
|
||||||
setImageLoaded(true);
|
if (img.complete) setImageLoaded(true);
|
||||||
};
|
|
||||||
// If image is already cached, trigger load immediately
|
|
||||||
if (img.complete) {
|
|
||||||
setImageLoaded(true);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOKTALogin = async () => {
|
const handleOKTALogin = async () => {
|
||||||
// Clear any existing session data
|
// Preserve force-reauth flag so Okta gets prompt=login after logout (must re-enter password)
|
||||||
|
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('========================================');
|
console.error('OKTA LOGIN ERROR', loginError);
|
||||||
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 = () => {
|
||||||
// Clear any existing session data
|
setTanflowError(null);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
|
sessionStorage.setItem('auth_provider', 'tanflow');
|
||||||
|
|
||||||
setTanflowLoading(true);
|
setTanflowLoading(true);
|
||||||
try {
|
try {
|
||||||
initiateTanflowLogin();
|
initiateTanflowLogin();
|
||||||
} catch (loginError) {
|
// If no throw, redirect is in progress
|
||||||
console.error('========================================');
|
} catch (loginError: unknown) {
|
||||||
console.error('TANFLOW LOGIN ERROR');
|
const message = loginError instanceof Error ? loginError.message : 'Dealer login failed. Check console for details.';
|
||||||
console.error('Error details:', loginError);
|
console.error('TANFLOW LOGIN ERROR', loginError);
|
||||||
|
setTanflowError(message);
|
||||||
setTanflowLoading(false);
|
setTanflowLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Auth Error in Auth Component:', {
|
console.error('Auth Error:', { message: error.message, error });
|
||||||
message: error.message,
|
|
||||||
error: error
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen flex items-center justify-center p-4 relative"
|
className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden"
|
||||||
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>
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 to-slate-800" />
|
||||||
)}
|
)}
|
||||||
{/* Overlay for better readability */}
|
{/* Blurred overlay so background stays subdued */}
|
||||||
<div className="absolute inset-0 bg-black/40"></div>
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[2px]" aria-hidden />
|
||||||
|
<div className="absolute inset-0 bg-black/30" aria-hidden />
|
||||||
|
|
||||||
<Card className="w-full max-w-md shadow-xl relative z-10 bg-black backdrop-blur-sm border-gray-800">
|
<Card className="w-full max-w-md shadow-2xl relative z-10 bg-gray-900/95 border border-gray-700 text-white">
|
||||||
<CardHeader className="space-y-1 text-center pb-6">
|
<CardHeader className="space-y-1 text-center pb-6 pt-8">
|
||||||
<div className="flex flex-col items-center justify-center mb-4">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={ReLogo}
|
src={ReLogo}
|
||||||
alt="Royal Enfield Logo"
|
alt="Royal Enfield"
|
||||||
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
className="h-9 w-auto max-w-[180px] object-contain mb-2"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-300 text-center truncate">Approval Portal</p>
|
<p className="text-sm text-gray-400">Approval Portal</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-5 pb-8 px-8">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-900/50 border border-red-700 text-red-200 px-4 py-3 rounded-lg">
|
<div className="bg-red-900/40 border border-red-700 text-red-200 px-4 py-3 rounded-lg text-sm">
|
||||||
<p className="text-sm font-medium">Authentication Error</p>
|
<p className="font-medium">Authentication Error</p>
|
||||||
<p className="text-sm">{error.message}</p>
|
<p>{error.message}</p>
|
||||||
|
{(error.message?.includes('401') || error.message?.toLowerCase().includes('unauthorized')) && (
|
||||||
|
<p className="mt-2 text-xs text-red-300">
|
||||||
|
If you see 401 from Okta: your Okta admin must add this site’s 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>
|
||||||
|
)}
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleOKTALogin}
|
onClick={handleOKTALogin}
|
||||||
disabled={isLoading || tanflowLoading}
|
disabled={isLoading || tanflowLoading}
|
||||||
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
|
className="w-full h-12 bg-re-red hover:bg-re-red/90 text-white font-semibold text-base border-0"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
<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" />
|
<ArrowRight className="mr-2 h-5 w-5" />
|
||||||
RE Employee Login
|
RE Employee Login
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="flex items-center gap-3">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<span className="flex-1 h-px bg-gray-600" />
|
||||||
<span className="w-full border-t border-gray-700"></span>
|
<span className="text-sm text-gray-400 uppercase tracking-wide">Or</span>
|
||||||
</div>
|
<span className="flex-1 h-px bg-gray-600" />
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-gray-900 px-2 text-gray-400">Or</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleTanflowLogin}
|
onClick={handleTanflowLogin}
|
||||||
disabled={isLoading || tanflowLoading}
|
disabled={isLoading || tanflowLoading}
|
||||||
className="w-full h-12 text-base font-semibold bg-indigo-600 hover:bg-indigo-700 text-white"
|
className="w-full h-12 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-base border-0"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{tanflowLoading ? (
|
{tanflowLoading ? (
|
||||||
<>
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
<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" />
|
<Shield className="mr-2 h-5 w-5" />
|
||||||
@ -153,11 +141,10 @@ export function Auth() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-400 mt-4">
|
<div className="text-center pt-2">
|
||||||
<p>Secure Single Sign-On</p>
|
<p className="text-sm text-gray-400">Secure Single Sign On</p>
|
||||||
<p className="text-xs mt-1 text-gray-500">Choose your authentication provider</p>
|
<p className="text-xs text-gray-500 mt-1">Choose your authentication provider.</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -45,103 +45,84 @@ 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, {
|
fetchRef.current(storedPage, toApiFilters());
|
||||||
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]); // Re-fetch if dealer status changes
|
}, [isDealer]);
|
||||||
|
|
||||||
// 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); // Reset to page 1 when filters change
|
filters.setCurrentPage(1);
|
||||||
fetchRef.current(1, {
|
fetchRef.current(1, 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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.sortBy, filters.sortOrder, isDealer]);
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.form16FinancialYear, filters.form16Quarter, 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); // Update page in Redux
|
filters.setCurrentPage(newPage);
|
||||||
closedRequests.fetchRequests(newPage, {
|
closedRequests.fetchRequests(newPage, 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]
|
[closedRequests, filters, toApiFilters]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh handler
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
closedRequests.handleRefresh({
|
closedRequests.handleRefresh(toApiFilters());
|
||||||
search: filters.searchTerm || undefined,
|
}, [closedRequests, toApiFilters]);
|
||||||
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">
|
||||||
@ -159,19 +140,17 @@ 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={
|
activeFiltersCount={filters.activeFiltersCount}
|
||||||
isDealer
|
|
||||||
? // For dealers: only count search and status (closure type)
|
|
||||||
[filters.searchTerm, filters.statusFilter !== 'all' ? filters.statusFilter : null].filter(Boolean).length
|
|
||||||
: // For standard users: count all filters
|
|
||||||
filters.activeFiltersCount
|
|
||||||
}
|
|
||||||
onSearchChange={filters.setSearchTerm}
|
onSearchChange={filters.setSearchTerm}
|
||||||
onPriorityChange={filters.setPriorityFilter}
|
onPriorityChange={filters.setPriorityFilter}
|
||||||
onStatusChange={filters.setStatusFilter}
|
onStatusChange={filters.setStatusFilter}
|
||||||
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
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}
|
||||||
|
|||||||
@ -73,6 +73,9 @@ 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';
|
||||||
}
|
}
|
||||||
@ -94,6 +97,27 @@ 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">
|
||||||
|
|||||||
@ -30,21 +30,13 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fetchRequests = useCallback(
|
const fetchRequests = useCallback(
|
||||||
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
|
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: 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,
|
||||||
@ -52,6 +44,8 @@ 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
|
||||||
});
|
});
|
||||||
@ -91,7 +85,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
|
|||||||
// Initial fetch removed - component handles initial fetch using Redux stored page
|
// Initial fetch removed - component handles initial fetch using Redux stored page
|
||||||
// This prevents duplicate fetches and allows page persistence
|
// This prevents duplicate fetches and allows page persistence
|
||||||
|
|
||||||
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
|
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRequests(pagination.currentPage, filters);
|
fetchRequests(pagination.currentPage, filters);
|
||||||
}, [fetchRequests, pagination.currentPage]);
|
}, [fetchRequests, pagination.currentPage]);
|
||||||
|
|||||||
@ -10,6 +10,8 @@ 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,
|
||||||
@ -26,14 +28,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);
|
||||||
|
|
||||||
// Get filters from Redux
|
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, form16FinancialYear, form16Quarter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
|
||||||
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]);
|
||||||
@ -44,10 +46,12 @@ 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, sortBy, sortOrder]);
|
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, form16FinancialYear, form16Quarter, sortBy, sortOrder]);
|
||||||
|
|
||||||
// Debounced filter change handler
|
// Debounced filter change handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -74,7 +78,7 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
clearTimeout(debounceTimeoutRef.current);
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
|
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, form16FinancialYear, form16Quarter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
dispatch(clearFiltersAction());
|
dispatch(clearFiltersAction());
|
||||||
@ -84,7 +88,9 @@ 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 {
|
||||||
@ -92,6 +98,8 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
templateTypeFilter,
|
templateTypeFilter,
|
||||||
|
form16FinancialYear,
|
||||||
|
form16Quarter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
currentPage,
|
currentPage,
|
||||||
@ -99,6 +107,8 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setTemplateTypeFilter,
|
setTemplateTypeFilter,
|
||||||
|
setForm16FinancialYear,
|
||||||
|
setForm16Quarter,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
@ -5,6 +5,8 @@ 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;
|
||||||
@ -15,6 +17,8 @@ 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,
|
||||||
@ -36,6 +40,12 @@ 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;
|
||||||
},
|
},
|
||||||
@ -50,6 +60,8 @@ 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;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -60,6 +72,8 @@ export const {
|
|||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setTemplateTypeFilter,
|
setTemplateTypeFilter,
|
||||||
|
setForm16FinancialYear,
|
||||||
|
setForm16Quarter,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
@ -18,6 +18,18 @@ 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 {
|
||||||
@ -29,6 +41,8 @@ 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
218
src/pages/Form16/Form16CreditNoteDetail.tsx
Normal file
218
src/pages/Form16/Form16CreditNoteDetail.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/pages/Form16/Form16CreditNotes.tsx
Normal file
210
src/pages/Form16/Form16CreditNotes.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
334
src/pages/Form16/Form16NonSubmittedDealers.tsx
Normal file
334
src/pages/Form16/Form16NonSubmittedDealers.tsx
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
425
src/pages/Form16/Form16PendingSubmissions.tsx
Normal file
425
src/pages/Form16/Form16PendingSubmissions.tsx
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* 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 "New Submission" 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/pages/Form16/Form16SubmissionResult.tsx
Normal file
76
src/pages/Form16/Form16SubmissionResult.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
582
src/pages/Form16/Form16Submit.tsx
Normal file
582
src/pages/Form16/Form16Submit.tsx
Normal file
@ -0,0 +1,582 @@
|
|||||||
|
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'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 2–3 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
854
src/pages/Form16/Form16_26AS.tsx
Normal file
854
src/pages/Form16/Form16_26AS.tsx
Normal file
@ -0,0 +1,854 @@
|
|||||||
|
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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/pages/Form16/components/RequestSubmissionSuccess.tsx
Normal file
267
src/pages/Form16/components/RequestSubmissionSuccess.tsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/pages/Form16/components/StatusChip.tsx
Normal file
28
src/pages/Form16/components/StatusChip.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/pages/Form16/components/TimelineStep.tsx
Normal file
38
src/pages/Form16/components/TimelineStep.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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 />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/pages/Form16/components/UploadCard.tsx
Normal file
128
src/pages/Form16/components/UploadCard.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -103,11 +103,10 @@ 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 */}
|
{/* Template Type Badge – Form 16 shows green "Form 16" */}
|
||||||
{(() => {
|
{(() => {
|
||||||
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';
|
||||||
@ -115,6 +114,9 @@ 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';
|
||||||
}
|
}
|
||||||
@ -146,9 +148,18 @@ 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 */}
|
{/* Current Approver and Level Info – Form 16 shows "Form 16 OCR FLOW" */}
|
||||||
<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">
|
||||||
|
<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 font-medium text-emerald-700" data-testid="form16-ocr-flow">
|
||||||
|
Form 16 OCR FLOW
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<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" />
|
<User 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 truncate" data-testid="current-approver">
|
<span className="text-xs sm:text-sm truncate" data-testid="current-approver">
|
||||||
@ -163,6 +174,8 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
|||||||
<span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
<span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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" />
|
||||||
|
|||||||
@ -30,6 +30,18 @@ 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 {
|
||||||
@ -139,15 +151,19 @@ 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) => {
|
||||||
return {
|
const params: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string } = {
|
||||||
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: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
templateType: 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)
|
||||||
@ -157,7 +173,7 @@ 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; sortBy?: string; sortOrder?: string }) => {
|
const fetchRequests = useCallback(async (page: number = 1, filterParams?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -174,6 +190,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
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
|
||||||
});
|
});
|
||||||
@ -216,6 +234,7 @@ 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);
|
||||||
@ -318,7 +337,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.sortBy, filters.sortOrder, isDealer]);
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.form16FinancialYear, filters.form16Quarter, 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
|
||||||
@ -365,12 +384,16 @@ 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}
|
||||||
@ -381,7 +404,22 @@ 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 statusConfig = getStatusConfig(request.status);
|
const isForm16 = ((request as any).templateType || (request as any).template_type || '').toString().toUpperCase() === 'FORM_16';
|
||||||
|
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
|
||||||
@ -435,6 +473,9 @@ 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';
|
||||||
}
|
}
|
||||||
@ -456,6 +497,27 @@ 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
|
||||||
|
|||||||
@ -9,6 +9,8 @@ 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,
|
||||||
@ -18,14 +20,14 @@ import {
|
|||||||
export function useOpenRequestsFilters() {
|
export function useOpenRequestsFilters() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
// Get filters from Redux
|
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, form16FinancialYear, form16Quarter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.openRequests);
|
||||||
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]);
|
||||||
@ -35,7 +37,9 @@ 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 {
|
||||||
@ -43,6 +47,8 @@ export function useOpenRequestsFilters() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
templateTypeFilter,
|
templateTypeFilter,
|
||||||
|
form16FinancialYear,
|
||||||
|
form16Quarter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
currentPage,
|
currentPage,
|
||||||
@ -50,6 +56,8 @@ export function useOpenRequestsFilters() {
|
|||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setTemplateTypeFilter,
|
setTemplateTypeFilter,
|
||||||
|
setForm16FinancialYear,
|
||||||
|
setForm16Quarter,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
@ -5,6 +5,8 @@ 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;
|
||||||
@ -15,6 +17,8 @@ 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,
|
||||||
@ -36,6 +40,12 @@ 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;
|
||||||
},
|
},
|
||||||
@ -50,6 +60,8 @@ 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;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -60,6 +72,8 @@ export const {
|
|||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setTemplateTypeFilter,
|
setTemplateTypeFilter,
|
||||||
|
setForm16FinancialYear,
|
||||||
|
setForm16Quarter,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
@ -31,7 +31,18 @@ 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 statusConfig = getStatusConfig(request?.status || 'pending');
|
const isForm16 = (request?.templateType || request?.template_type || '').toString().toUpperCase() === 'FORM_16';
|
||||||
|
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">
|
||||||
@ -77,27 +88,33 @@ export function RequestDetailHeader({
|
|||||||
>
|
>
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* Template Type Badge */}
|
{/* Template Type Badge – Form 16 shows green "Form 16", others unchanged */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const workflowType = request?.workflowType || request?.workflow_type;
|
const workflowType = request?.workflowType || request?.workflow_type;
|
||||||
const templateType = request?.templateType || request?.template_type || '';
|
const templateTypeRaw = request?.templateType || request?.template_type || '';
|
||||||
const templateTypeUpper = templateType?.toUpperCase() || '';
|
const templateTypeUpper = templateTypeRaw?.toString().toUpperCase() || '';
|
||||||
|
|
||||||
// Check for dealer claim - support multiple formats
|
const isForm16 = templateTypeUpper === 'FORM_16';
|
||||||
const isDealerClaim =
|
const isClaimManagement =
|
||||||
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 (isDealerClaim) {
|
if (isForm16) {
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 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';
|
||||||
@ -11,7 +12,98 @@ interface ActivityTabProps {
|
|||||||
request: any;
|
request: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build Form 16–specific 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>
|
||||||
@ -19,27 +111,29 @@ 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">Complete audit trail of all request activities</CardDescription>
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
|
{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">
|
||||||
{request.auditTrail && request.auditTrail.length > 0 ? (
|
{allEntries.length > 0 ? (
|
||||||
request.auditTrail.map((entry: any, index: number) => (
|
allEntries.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 bg-gray-100 flex items-center justify-center">
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${entry.type === 'rejection' ? 'bg-red-50' : 'bg-gray-100'}`}>
|
||||||
{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="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
|
<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="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h4 className="font-semibold text-gray-900">{entry.action}</h4>
|
<h4 className={`font-semibold ${entry.type === 'rejection' ? 'text-red-900' : '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 text-gray-600 leading-relaxed">
|
<div className={`text-sm leading-relaxed ${entry.type === 'rejection' ? 'text-red-800' : 'text-gray-600'}`}>
|
||||||
<p className="whitespace-pre-line break-words">{entry.details}</p>
|
<p className="whitespace-pre-line break-words">{entry.details}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,19 +26,79 @@ 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">
|
||||||
{/* Request Documents */}
|
{/* Form 16: Previous submission(s) documents — only same FY & quarter as this request */}
|
||||||
|
{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'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" />
|
||||||
Request Documents
|
{isForm16 ? 'Current submission' : 'Request Documents'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs sm:text-sm mt-1">
|
<CardDescription className="text-xs sm:text-sm mt-1">
|
||||||
Documents attached while creating the request
|
{isForm16 ? 'Documents for this Form 16A submission' : '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">
|
||||||
@ -78,7 +138,8 @@ export function DocumentsTab({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Work Note Attachments */}
|
{/* Work Note Attachments – hidden for Form 16 (no work notes in Form 16 flow) */}
|
||||||
|
{!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">
|
||||||
@ -118,6 +179,7 @@ export function DocumentsTab({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 } from 'lucide-react';
|
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2, Pause, Play, AlertCircle, Scan } 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';
|
||||||
@ -70,9 +71,213 @@ 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">
|
||||||
|
|||||||
@ -372,6 +372,8 @@ 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,
|
||||||
@ -402,6 +404,8 @@ 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 ||
|
||||||
@ -423,6 +427,8 @@ 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,
|
||||||
@ -443,6 +449,8 @@ 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,
|
||||||
@ -536,6 +544,55 @@ 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">
|
||||||
@ -582,6 +639,7 @@ 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>
|
||||||
|
|
||||||
@ -616,7 +674,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Filters - Initiator and Approver */}
|
{/* User Filters - Initiator and Approver (hidden when Form 16 is selected) */}
|
||||||
<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">
|
||||||
@ -842,6 +900,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -30,8 +30,10 @@ 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) {
|
||||||
@ -57,23 +59,45 @@ 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) {
|
if (!isDealer) return filterOptions;
|
||||||
// For dealers, exclude priority, templateType, department, and slaCompliance
|
const rt = filters.requestTypeFilter;
|
||||||
|
if (rt === 'form_16') {
|
||||||
|
return {
|
||||||
|
templateType: 'FORM_16',
|
||||||
|
financialYear: filters.form16FinancialYear !== 'all' ? filters.form16FinancialYear : undefined,
|
||||||
|
quarter: filters.form16Quarter !== 'all' ? filters.form16Quarter : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (rt === 'claim_management') {
|
||||||
|
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;
|
const { priority, templateType, department, slaCompliance, ...dealerFilters } = filterOptions;
|
||||||
return dealerFilters;
|
return dealerFilters;
|
||||||
}
|
|
||||||
return filterOptions;
|
|
||||||
}, [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) {
|
||||||
// For dealers: only count search, status, initiator, approver, and date filters
|
const rt = filters.requestTypeFilter;
|
||||||
|
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' ||
|
||||||
@ -84,7 +108,6 @@ 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]);
|
||||||
|
|
||||||
@ -95,6 +118,7 @@ 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);
|
||||||
@ -178,6 +202,13 @@ 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);
|
||||||
@ -459,6 +490,13 @@ 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}
|
||||||
|
|||||||
@ -47,8 +47,23 @@ 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 statusConfig = getStatusConfig(request.status);
|
const isForm16 = (request?.templateType || (request as any)?.template_type || '').toString().toUpperCase() === 'FORM_16';
|
||||||
|
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;
|
||||||
@ -82,7 +97,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">{request.status}</span>
|
<span className="capitalize">{statusConfig.label}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
|
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
|
||||||
<Badge
|
<Badge
|
||||||
@ -102,11 +117,10 @@ 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 */}
|
{/* Template Type Badge – Form 16 shows green "Form 16", others unchanged */}
|
||||||
{(() => {
|
{(() => {
|
||||||
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,6 +128,9 @@ 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';
|
||||||
}
|
}
|
||||||
@ -151,14 +168,33 @@ 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 */}
|
{/* Current Approver and Level Info – Form 16 shows "Form 16 OCR FLOW" instead */}
|
||||||
<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">
|
||||||
|
<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 font-medium text-emerald-700" data-testid="form16-ocr-flow">
|
||||||
|
Form 16 OCR FLOW
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<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" />
|
<User 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 truncate" data-testid="current-approver">
|
<span className="text-xs sm:text-sm truncate" data-testid="current-approver">
|
||||||
@ -171,6 +207,8 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
|||||||
<span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
<span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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" />
|
||||||
|
|||||||
@ -7,11 +7,15 @@ 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,
|
||||||
@ -34,6 +38,9 @@ export function useRequestsFilters() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
templateTypeFilter,
|
templateTypeFilter,
|
||||||
|
requestTypeFilter,
|
||||||
|
form16FinancialYear,
|
||||||
|
form16Quarter,
|
||||||
slaComplianceFilter,
|
slaComplianceFilter,
|
||||||
departmentFilter,
|
departmentFilter,
|
||||||
initiatorFilter,
|
initiatorFilter,
|
||||||
@ -51,6 +58,9 @@ 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]);
|
||||||
@ -63,6 +73,14 @@ 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,
|
||||||
@ -82,6 +100,8 @@ export function useRequestsFilters() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
templateTypeFilter,
|
templateTypeFilter,
|
||||||
|
form16FinancialYear,
|
||||||
|
form16Quarter,
|
||||||
slaComplianceFilter,
|
slaComplianceFilter,
|
||||||
departmentFilter,
|
departmentFilter,
|
||||||
initiatorFilter,
|
initiatorFilter,
|
||||||
@ -119,11 +139,16 @@ export function useRequestsFilters() {
|
|||||||
}
|
}
|
||||||
}, [customStartDate, customEndDate, dispatch]);
|
}, [customStartDate, customEndDate, dispatch]);
|
||||||
|
|
||||||
const hasActiveFilters: boolean = !!(
|
const isForm16 = templateTypeFilter === 'FORM_16';
|
||||||
|
const hasActiveFilters: boolean = isForm16
|
||||||
|
? !!(templateTypeFilter === 'FORM_16' && (form16FinancialYear !== 'all' || form16Quarter !== 'all'))
|
||||||
|
: !!(
|
||||||
searchTerm ||
|
searchTerm ||
|
||||||
statusFilter !== 'all' ||
|
statusFilter !== 'all' ||
|
||||||
priorityFilter !== 'all' ||
|
priorityFilter !== 'all' ||
|
||||||
templateTypeFilter !== 'all' ||
|
templateTypeFilter !== 'all' ||
|
||||||
|
requestTypeFilter !== 'all' ||
|
||||||
|
((requestTypeFilter as RequestTypeFilter) === 'form_16' && (form16FinancialYear !== 'all' || form16Quarter !== 'all')) ||
|
||||||
slaComplianceFilter !== 'all' ||
|
slaComplianceFilter !== 'all' ||
|
||||||
departmentFilter !== 'all' ||
|
departmentFilter !== 'all' ||
|
||||||
initiatorFilter !== 'all' ||
|
initiatorFilter !== 'all' ||
|
||||||
@ -139,6 +164,10 @@ export function useRequestsFilters() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
templateTypeFilter,
|
templateTypeFilter,
|
||||||
|
requestTypeFilter,
|
||||||
|
form16FinancialYear,
|
||||||
|
form16Quarter,
|
||||||
|
isForm16,
|
||||||
slaComplianceFilter,
|
slaComplianceFilter,
|
||||||
departmentFilter,
|
departmentFilter,
|
||||||
initiatorFilter,
|
initiatorFilter,
|
||||||
@ -155,6 +184,9 @@ export function useRequestsFilters() {
|
|||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setTemplateTypeFilter,
|
setTemplateTypeFilter,
|
||||||
|
setRequestTypeFilter,
|
||||||
|
setForm16FinancialYear,
|
||||||
|
setForm16Quarter,
|
||||||
setSlaComplianceFilter,
|
setSlaComplianceFilter,
|
||||||
setDepartmentFilter,
|
setDepartmentFilter,
|
||||||
setInitiatorFilter,
|
setInitiatorFilter,
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
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;
|
||||||
@ -23,6 +32,9 @@ 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',
|
||||||
@ -50,6 +62,23 @@ 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;
|
||||||
@ -86,6 +115,9 @@ 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';
|
||||||
@ -105,6 +137,9 @@ export const {
|
|||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setTemplateTypeFilter,
|
setTemplateTypeFilter,
|
||||||
|
setRequestTypeFilter,
|
||||||
|
setForm16FinancialYear,
|
||||||
|
setForm16Quarter,
|
||||||
setSlaComplianceFilter,
|
setSlaComplianceFilter,
|
||||||
setDepartmentFilter,
|
setDepartmentFilter,
|
||||||
setInitiatorFilter,
|
setInitiatorFilter,
|
||||||
|
|||||||
@ -26,6 +26,10 @@ 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') {
|
||||||
@ -92,6 +96,10 @@ 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;
|
||||||
|
|||||||
@ -31,6 +31,8 @@ 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') {
|
||||||
@ -103,6 +105,8 @@ 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') {
|
||||||
|
|||||||
@ -13,6 +13,10 @@ 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;
|
||||||
@ -49,6 +53,13 @@ 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;
|
||||||
@ -65,5 +76,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,41 +27,47 @@ export const getPriorityConfig = (priority: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStatusConfig = (status: string) => {
|
export const getStatusConfig = (status: string): { color: string; label: string; icon: typeof CheckCircle; iconColor: 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'
|
||||||
};
|
};
|
||||||
|
|||||||
@ -99,7 +99,8 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ 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
|
||||||
@ -36,6 +37,7 @@ 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();
|
||||||
@ -359,7 +361,71 @@ 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">
|
||||||
{!showActivityTypeManager ? (
|
{showForm16AdminConfig ? (
|
||||||
|
<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">
|
||||||
@ -404,41 +470,37 @@ 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>
|
</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>
|
||||||
<div>
|
</CardContent>
|
||||||
<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>
|
</Card>
|
||||||
<ActivityTypeManager />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -83,6 +83,55 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -22,8 +22,14 @@ 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) => {
|
||||||
// In production, cookies are sent automatically with withCredentials: true
|
// FormData: do not set Content-Type so the browser sets multipart/form-data with boundary
|
||||||
// No need to set Authorization header
|
if (config.data instanceof FormData) {
|
||||||
|
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) {
|
||||||
@ -134,6 +140,26 @@ 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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
693
src/services/form16Api.ts
Normal file
693
src/services/form16Api.ts
Normal file
@ -0,0 +1,693 @@
|
|||||||
|
/**
|
||||||
|
* 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 1–2 min). */
|
||||||
|
const UPLOAD_26AS_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface Upload26asResult {
|
||||||
|
imported: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload 26AS TXT file with optional progress callback (0–100%).
|
||||||
|
* 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 : [];
|
||||||
|
}
|
||||||
@ -158,8 +158,41 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
|
|||||||
return { id: data?.requestId } as any;
|
return { id: data?.requestId } as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 } = {}) {
|
export async function listWorkflows(params: {
|
||||||
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
|
page?: number;
|
||||||
|
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,
|
||||||
@ -168,22 +201,59 @@ export async function listWorkflows(params: { page?: number; limit?: number; sea
|
|||||||
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: { 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 } = {}) {
|
export async function listParticipantRequests(params: {
|
||||||
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate } = params;
|
page?: number;
|
||||||
|
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,
|
||||||
@ -192,6 +262,8 @@ export async function listParticipantRequests(params: { page?: number; limit?: n
|
|||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
templateType,
|
templateType,
|
||||||
|
financialYear,
|
||||||
|
quarter,
|
||||||
department,
|
department,
|
||||||
initiator,
|
initiator,
|
||||||
approver,
|
approver,
|
||||||
@ -199,13 +271,14 @@ export async function listParticipantRequests(params: { page?: number; limit?: n
|
|||||||
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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,9 +334,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; sortBy?: string; sortOrder?: string } = {}) {
|
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 } = {}) {
|
||||||
const { page = 1, limit = 20, search, status, priority, templateType, sortBy, sortOrder } = params;
|
const { page = 1, limit = 20, search, status, priority, templateType, financialYear, quarter, sortBy, sortOrder } = params;
|
||||||
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit, search, status, priority, templateType, sortBy, sortOrder } });
|
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit, search, status, priority, templateType, financialYear, quarter, 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 || [],
|
||||||
@ -271,9 +344,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; sortBy?: string; sortOrder?: string } = {}) {
|
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 } = {}) {
|
||||||
const { page = 1, limit = 20, search, status, priority, templateType, sortBy, sortOrder } = params;
|
const { page = 1, limit = 20, search, status, priority, templateType, financialYear, quarter, sortBy, sortOrder } = params;
|
||||||
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit, search, status, priority, templateType, sortBy, sortOrder } });
|
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit, search, status, priority, templateType, financialYear, quarter, 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 || [],
|
||||||
|
|||||||
@ -65,47 +65,63 @@ 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) => {
|
export const getStatusConfig = (status: string): { color: string; label: string; icon: typeof Clock; iconColor: 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'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user