(Feat-Fix): Lots of fixes done, reporting system fixed, stricter types
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
# Quick Start Guide
|
# Quick Start Guide
|
||||||
|
|
||||||
|
|
||||||
## Start the Application
|
## Start the Application
|
||||||
|
|
||||||
### Option 1: Use the Start Script (Recommended)
|
### Option 1: Use the Start Script (Recommended)
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -1,16 +1,26 @@
|
|||||||
# React + Vite
|
# React + Vite
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
This template provides a minimal setup to get React working in Vite with HMR and
|
||||||
|
some ESLint rules.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react)
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in
|
||||||
|
[rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc)
|
||||||
|
uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
## React Compiler
|
## React Compiler
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
The React Compiler is not enabled on this template because of its impact on dev
|
||||||
|
& build performances. To add it, see
|
||||||
|
[this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
If you are developing a production application, we recommend using TypeScript
|
||||||
|
with type-aware lint rules enabled. Check out the
|
||||||
|
[TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts)
|
||||||
|
for information on how to integrate TypeScript and
|
||||||
|
[`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
|
|||||||
144
activities.md
144
activities.md
@@ -2,81 +2,81 @@
|
|||||||
|
|
||||||
## GROUNDNUT Department
|
## GROUNDNUT Department
|
||||||
|
|
||||||
| # | Activity Name | Sub-Department | Unit of Measurement |
|
| # | Activity Name | Sub-Department | Unit of Measurement |
|
||||||
|---|---------------|----------------|---------------------|
|
| -- | ---------------------------------------------------------------------------------------------- | ---------------------------------------- | --------------------- |
|
||||||
| 1 | Mufali Aavak Katai (Groundnut Arrival Cutting) | Loading/Unloading | Per Bag |
|
| 1 | Mufali Aavak Katai (Groundnut Arrival Cutting) | Loading/Unloading | Per Bag |
|
||||||
| 2 | Mufali Aavak Dhaang (Groundnut Arrival Stacking) | Loading/Unloading | Per Bag |
|
| 2 | Mufali Aavak Dhaang (Groundnut Arrival Stacking) | Loading/Unloading | Per Bag |
|
||||||
| 3 | Dhaang Se Katai (Cutting from Stack) | Loading/Unloading | Per Bag |
|
| 3 | Dhaang Se Katai (Cutting from Stack) | Loading/Unloading | Per Bag |
|
||||||
| 4 | Guthli Bori Silai Dhaang (Kernel Bag Stitching Stack) | Loading/Unloading | Per Bag |
|
| 4 | Guthli Bori Silai Dhaang (Kernel Bag Stitching Stack) | Loading/Unloading | Per Bag |
|
||||||
| 5 | Guthali dhada Pala Tulai Silai Dhaang / Loading (Kernel Heap Weighing Stitching Stack/Loading) | Loading/Unloading | Per Bag |
|
| 5 | Guthali dhada Pala Tulai Silai Dhaang / Loading (Kernel Heap Weighing Stitching Stack/Loading) | Loading/Unloading | Per Bag |
|
||||||
| 6 | Mufali Patthar Bori silai Dhaang (Groundnut Stone Bag Stitching Stack) | Loading/Unloading | Per Bag |
|
| 6 | Mufali Patthar Bori silai Dhaang (Groundnut Stone Bag Stitching Stack) | Loading/Unloading | Per Bag |
|
||||||
| 7 | Mufali Patthar Bori Utrai (Groundnut Stone Bag Unloading) | Loading/Unloading | Per Bag |
|
| 7 | Mufali Patthar Bori Utrai (Groundnut Stone Bag Unloading) | Loading/Unloading | Per Bag |
|
||||||
| 8 | Bardana Bandal Loading (Gunny Bundle Loading) | Loading/Unloading | Per Bag |
|
| 8 | Bardana Bandal Loading (Gunny Bundle Loading) | Loading/Unloading | Per Bag |
|
||||||
| 9 | Bardana Gatthi Loading/Unloading (Gunny Bale Loading/Unloading) | Loading/Unloading | Per Bag |
|
| 9 | Bardana Gatthi Loading/Unloading (Gunny Bale Loading/Unloading) | Loading/Unloading | Per Bag |
|
||||||
| 10 | Black Dana Loading/Unloading | Loading/Unloading | Per Bag |
|
| 10 | Black Dana Loading/Unloading | Loading/Unloading | Per Bag |
|
||||||
| 11 | Pre Cleaner | Pre Cleaning | Fixed Rate-Per Person |
|
| 11 | Pre Cleaner | Pre Cleaning | Fixed Rate-Per Person |
|
||||||
| 12 | Destoner | Destoner | Fixed Rate-Per Person |
|
| 12 | Destoner | Destoner | Fixed Rate-Per Person |
|
||||||
| 13 | Water | Water | Fixed Rate-Per Person |
|
| 13 | Water | Water | Fixed Rate-Per Person |
|
||||||
| 14 | Decordicater | Decordicater & Cleaning and Round Chalna | Fixed Rate-Per Person |
|
| 14 | Decordicater | Decordicater & Cleaning and Round Chalna | Fixed Rate-Per Person |
|
||||||
| 15 | Round Chalna (Round Sieving) | Decordicater & Cleaning and Round Chalna | Fixed Rate-Per Person |
|
| 15 | Round Chalna (Round Sieving) | Decordicater & Cleaning and Round Chalna | Fixed Rate-Per Person |
|
||||||
| 16 | Cleaning | Decordicater & Cleaning and Round Chalna | Fixed Rate-Per Person |
|
| 16 | Cleaning | Decordicater & Cleaning and Round Chalna | Fixed Rate-Per Person |
|
||||||
| 17 | Round Chalna No.1 (Round Sieving No.1) | Round Chalna No.1 | Fixed Rate-Per Person |
|
| 17 | Round Chalna No.1 (Round Sieving No.1) | Round Chalna No.1 | Fixed Rate-Per Person |
|
||||||
| 18 | Dala - Chomu & Jaipur (Branch - Chomu & Jaipur) | Loading/Unloading | Per Bag |
|
| 18 | Dala - Chomu & Jaipur (Branch - Chomu & Jaipur) | Loading/Unloading | Per Bag |
|
||||||
|
|
||||||
## DANA Department
|
## DANA Department
|
||||||
|
|
||||||
| # | Activity Name | Sub-Department | Unit of Measurement |
|
| # | Activity Name | Sub-Department | Unit of Measurement |
|
||||||
|---|---------------|----------------|---------------------|
|
| -- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------- |
|
||||||
| 1 | Tulai Silai Loading (Weighing Stitching Loading) | Loading/Unloading | Per Bag |
|
| 1 | Tulai Silai Loading (Weighing Stitching Loading) | Loading/Unloading | Per Bag |
|
||||||
| 2 | Dhaang se Loading (Loading from Stack) | Loading/Unloading | Per Bag |
|
| 2 | Dhaang se Loading (Loading from Stack) | Loading/Unloading | Per Bag |
|
||||||
| 3 | Silai Dhaang (Stitching Stack) | Loading/Unloading | Per Bag |
|
| 3 | Silai Dhaang (Stitching Stack) | Loading/Unloading | Per Bag |
|
||||||
| 4 | Tulai Silai Dhaang Ikai No. 2 Machine ke Pass (Weighing Stitching Stack Unit No. 2 Near Machine) | Loading/Unloading | Per Bag |
|
| 4 | Tulai Silai Dhaang Ikai No. 2 Machine ke Pass (Weighing Stitching Stack Unit No. 2 Near Machine) | Loading/Unloading | Per Bag |
|
||||||
| 5 | Dana Unloading/Dhaang (Grain Unloading/Stack) | Loading/Unloading | Per Bag |
|
| 5 | Dana Unloading/Dhaang (Grain Unloading/Stack) | Loading/Unloading | Per Bag |
|
||||||
| 6 | Dana Aavak Keep Katai (Grain Arrival Hopper Cutting) | Loading/Unloading | Per Bag |
|
| 6 | Dana Aavak Keep Katai (Grain Arrival Hopper Cutting) | Loading/Unloading | Per Bag |
|
||||||
| 7 | Kachri Dhada Pala Bharai Tulai Silai Load/Dhaang 70kg (Waste Heap Filling Weighing Stitching Load/Stack 70kg) | Loading/Unloading | Per Bag |
|
| 7 | Kachri Dhada Pala Bharai Tulai Silai Load/Dhaang 70kg (Waste Heap Filling Weighing Stitching Load/Stack 70kg) | Loading/Unloading | Per Bag |
|
||||||
| 8 | Kachri Dhaang se loading (Waste Loading from Stack) | Loading/Unloading | Per Bag |
|
| 8 | Kachri Dhaang se loading (Waste Loading from Stack) | Loading/Unloading | Per Bag |
|
||||||
| 9 | Keep Katai Khulla Katta (Hopper Cutting Open Bag) | Loading/Unloading | Per Bag |
|
| 9 | Keep Katai Khulla Katta (Hopper Cutting Open Bag) | Loading/Unloading | Per Bag |
|
||||||
| 10 | Keep Katai Silai Kholkar (Hopper Cutting Opening Stitched) | Loading/Unloading | Per Bag |
|
| 10 | Keep Katai Silai Kholkar (Hopper Cutting Opening Stitched) | Loading/Unloading | Per Bag |
|
||||||
| 11 | Bardana Paltai (Gunny Turning) | Loading/Unloading | Per Bag |
|
| 11 | Bardana Paltai (Gunny Turning) | Loading/Unloading | Per Bag |
|
||||||
| 12 | Ekai No. 2 me Keep Katai Khula Bag (Khichai Sahit Tank Me) (Unit No. 2 Hopper Cutting Open Bag with Dragging into Tank) | Loading/Unloading | Per Bag |
|
| 12 | Ekai No. 2 me Keep Katai Khula Bag (Khichai Sahit Tank Me) (Unit No. 2 Hopper Cutting Open Bag with Dragging into Tank) | Loading/Unloading | Per Bag |
|
||||||
| 13 | Ekai No. 2 me Keep Katai Silai Kholkar (Khichai Sahit Tank Me) (Unit No. 2 Hopper Cutting Opening Stitched with Dragging into Tank) | Loading/Unloading | Per Bag |
|
| 13 | Ekai No. 2 me Keep Katai Silai Kholkar (Khichai Sahit Tank Me) (Unit No. 2 Hopper Cutting Opening Stitched with Dragging into Tank) | Loading/Unloading | Per Bag |
|
||||||
| 14 | Silai Loading Company Gadi Dala Sahit (Stitching Loading Company Vehicle with Branch) | Loading/Unloading | Per Bag |
|
| 14 | Silai Loading Company Gadi Dala Sahit (Stitching Loading Company Vehicle with Branch) | Loading/Unloading | Per Bag |
|
||||||
| 15 | Kachri Bharai Silai Dhaang Chatt Par (Waste Filling Stitching Stack on Roof) | Loading/Unloading | Per Bag |
|
| 15 | Kachri Bharai Silai Dhaang Chatt Par (Waste Filling Stitching Stack on Roof) | Loading/Unloading | Per Bag |
|
||||||
| 16 | Bardana Unloading (Gunny Unloading) | Loading/Unloading | Per Bag |
|
| 16 | Bardana Unloading (Gunny Unloading) | Loading/Unloading | Per Bag |
|
||||||
| 17 | Grading | Loading/Unloading | Per Bag |
|
| 17 | Grading | Loading/Unloading | Per Bag |
|
||||||
| 18 | Destoner | Destoner | Fixed Rate-Per Person |
|
| 18 | Destoner | Destoner | Fixed Rate-Per Person |
|
||||||
| 19 | Gravity | Gravity | Fixed Rate-Per Person |
|
| 19 | Gravity | Gravity | Fixed Rate-Per Person |
|
||||||
| 20 | Tank | Tank | Fixed Rate-Per Person |
|
| 20 | Tank | Tank | Fixed Rate-Per Person |
|
||||||
| 21 | Sortex | Sortex | Fixed Rate-Per Person |
|
| 21 | Sortex | Sortex | Fixed Rate-Per Person |
|
||||||
| 22 | X-Ray | X-Ray | Fixed Rate-Per Person |
|
| 22 | X-Ray | X-Ray | Fixed Rate-Per Person |
|
||||||
| 23 | Kachri (Waste) | Kachri | Fixed Rate-Per Person |
|
| 23 | Kachri (Waste) | Kachri | Fixed Rate-Per Person |
|
||||||
| 24 | Other Works | Other Works | Fixed Rate-Per Person |
|
| 24 | Other Works | Other Works | Fixed Rate-Per Person |
|
||||||
|
|
||||||
## TUKDI Department
|
## TUKDI Department
|
||||||
|
|
||||||
| # | Activity Name | Sub-Department | Unit of Measurement |
|
| # | Activity Name | Sub-Department | Unit of Measurement |
|
||||||
|---|---------------|----------------|---------------------|
|
| -- | ----------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------- |
|
||||||
| 1 | Dana Loaning/Unloading (Grain Loading/Unloading) | Loading/Unloading | Per Bag |
|
| 1 | Dana Loaning/Unloading (Grain Loading/Unloading) | Loading/Unloading | Per Bag |
|
||||||
| 2 | Loading/Unloading 40 Kg | Loading/Unloading | Per Bag |
|
| 2 | Loading/Unloading 40 Kg | Loading/Unloading | Per Bag |
|
||||||
| 3 | Grading Chalne se Maal Bharai Tulai Silai Dhaang (Grading Running Material Filling Weighing Stitching Stack) | Loading/Unloading | Per Bag |
|
| 3 | Grading Chalne se Maal Bharai Tulai Silai Dhaang (Grading Running Material Filling Weighing Stitching Stack) | Loading/Unloading | Per Bag |
|
||||||
| 4 | Grading Chalne se Maal Bharai Tulai Silai Loading (Grading Running Material Filling Weighing Stitching Loading) | Loading/Unloading | Per Bag |
|
| 4 | Grading Chalne se Maal Bharai Tulai Silai Loading (Grading Running Material Filling Weighing Stitching Loading) | Loading/Unloading | Per Bag |
|
||||||
| 5 | Chilka Bharai silai Dhaang (Husk Filling Stitching Stack) | Loading/Unloading | Per Bag |
|
| 5 | Chilka Bharai silai Dhaang (Husk Filling Stitching Stack) | Loading/Unloading | Per Bag |
|
||||||
| 6 | Keep katai Bahar Se (Hopper Cutting from Outside) | Loading/Unloading | Per Bag |
|
| 6 | Keep katai Bahar Se (Hopper Cutting from Outside) | Loading/Unloading | Per Bag |
|
||||||
| 7 | Keep katai Andar Se (Hopper Cutting from Inside) | Loading/Unloading | Per Bag |
|
| 7 | Keep katai Andar Se (Hopper Cutting from Inside) | Loading/Unloading | Per Bag |
|
||||||
| 8 | Cartoon Banai Vacume Bharai Tulai Packing and Dhaang (Carton Making Vacuum Filling Weighing Packing and Stack) | Loading/Unloading | Per Bag |
|
| 8 | Cartoon Banai Vacume Bharai Tulai Packing and Dhaang (Carton Making Vacuum Filling Weighing Packing and Stack) | Loading/Unloading | Per Bag |
|
||||||
| 9 | Cartoon Banai Vacume Bharai Tulai Packing and Loading (Carton Making Vacuum Filling Weighing Packing and Loading) | Loading/Unloading | Per Bag |
|
| 9 | Cartoon Banai Vacume Bharai Tulai Packing and Loading (Carton Making Vacuum Filling Weighing Packing and Loading) | Loading/Unloading | Per Bag |
|
||||||
| 10 | Katta Paltai (Bag Turning) | Loading/Unloading | Per Bag |
|
| 10 | Katta Paltai (Bag Turning) | Loading/Unloading | Per Bag |
|
||||||
| 11 | Dhada Pala Bharai Tulai Silai Dhaang (Heap Filling Weighing Stitching Stack) | Loading/Unloading | Per Bag |
|
| 11 | Dhada Pala Bharai Tulai Silai Dhaang (Heap Filling Weighing Stitching Stack) | Loading/Unloading | Per Bag |
|
||||||
| 12 | Dhada Pala Bharai Tulai Silai Loading (Heap Filling Weighing Stitching Loading) | Loading/Unloading | Per Bag |
|
| 12 | Dhada Pala Bharai Tulai Silai Loading (Heap Filling Weighing Stitching Loading) | Loading/Unloading | Per Bag |
|
||||||
| 13 | Sike Maal Ki Silai Dhaang Andar (Roasted Material Stitching Stack Inside) | Loading/Unloading | Per Bag |
|
| 13 | Sike Maal Ki Silai Dhaang Andar (Roasted Material Stitching Stack Inside) | Loading/Unloading | Per Bag |
|
||||||
| 14 | Sike Maal Ki Silai Dhaang Bahar (Roasted Material Stitching Stack Outside) | Loading/Unloading | Per Bag |
|
| 14 | Sike Maal Ki Silai Dhaang Bahar (Roasted Material Stitching Stack Outside) | Loading/Unloading | Per Bag |
|
||||||
| 15 | Nakku Silai Dhaang Bahar (Rejection Stitching Stack Outside) | Loading/Unloading | Per Bag |
|
| 15 | Nakku Silai Dhaang Bahar (Rejection Stitching Stack Outside) | Loading/Unloading | Per Bag |
|
||||||
| 16 | Tank | Tank | Fixed Rate-Per Person |
|
| 16 | Tank | Tank | Fixed Rate-Per Person |
|
||||||
| 17 | Grader (Machine) | Grader (Machine) | Fixed Rate-Per Person |
|
| 17 | Grader (Machine) | Grader (Machine) | Fixed Rate-Per Person |
|
||||||
| 18 | Sortex | Sortex | Fixed Rate-Per Person |
|
| 18 | Sortex | Sortex | Fixed Rate-Per Person |
|
||||||
| 19 | X-Ray | X-Ray | Fixed Rate-Per Person |
|
| 19 | X-Ray | X-Ray | Fixed Rate-Per Person |
|
||||||
| 20 | Rejection | Rejection | Fixed Rate-Per Person |
|
| 20 | Rejection | Rejection | Fixed Rate-Per Person |
|
||||||
| 21 | Store | Store | Fixed Rate-Per Person |
|
| 21 | Store | Store | Fixed Rate-Per Person |
|
||||||
| 22 | Roster | Roster | Fixed Rate-Per Person |
|
| 22 | Roster | Roster | Fixed Rate-Per Person |
|
||||||
| 23 | Blancher | Blancher | Fixed Rate-Per Person |
|
| 23 | Blancher | Blancher | Fixed Rate-Per Person |
|
||||||
| 24 | Other Works | Other Works | Fixed Rate-Per Person |
|
| 24 | Other Works | Other Works | Fixed Rate-Per Person |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Work Allocation Backend - Deno TypeScript
|
# Work Allocation Backend - Deno TypeScript
|
||||||
|
|
||||||
A secure, type-safe backend for the Work Allocation System built with Deno and TypeScript.
|
A secure, type-safe backend for the Work Allocation System built with Deno and
|
||||||
|
TypeScript.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -90,7 +91,8 @@ deno task seed
|
|||||||
- `GET /api/departments/:id` - Get department
|
- `GET /api/departments/:id` - Get department
|
||||||
- `GET /api/departments/:id/sub-departments` - Get sub-departments
|
- `GET /api/departments/:id/sub-departments` - Get sub-departments
|
||||||
- `POST /api/departments` - Create department (SuperAdmin)
|
- `POST /api/departments` - Create department (SuperAdmin)
|
||||||
- `POST /api/departments/:id/sub-departments` - Create sub-department (SuperAdmin)
|
- `POST /api/departments/:id/sub-departments` - Create sub-department
|
||||||
|
(SuperAdmin)
|
||||||
|
|
||||||
### Work Allocations
|
### Work Allocations
|
||||||
|
|
||||||
@@ -122,21 +124,21 @@ deno task seed
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
| ------------------------- | ----------------------- | ----------------------- |
|
||||||
| `PORT` | Server port | 3000 |
|
| `PORT` | Server port | 3000 |
|
||||||
| `DB_HOST` | Database host | localhost |
|
| `DB_HOST` | Database host | localhost |
|
||||||
| `DB_USER` | Database user | root |
|
| `DB_USER` | Database user | root |
|
||||||
| `DB_PASSWORD` | Database password | admin123 |
|
| `DB_PASSWORD` | Database password | admin123 |
|
||||||
| `DB_NAME` | Database name | work_allocation |
|
| `DB_NAME` | Database name | work_allocation |
|
||||||
| `DB_PORT` | Database port | 3306 |
|
| `DB_PORT` | Database port | 3306 |
|
||||||
| `JWT_SECRET` | JWT signing secret | (change in production!) |
|
| `JWT_SECRET` | JWT signing secret | (change in production!) |
|
||||||
| `JWT_EXPIRES_IN` | Token expiration | 7d |
|
| `JWT_EXPIRES_IN` | Token expiration | 7d |
|
||||||
| `BCRYPT_ROUNDS` | Password hash rounds | 12 |
|
| `BCRYPT_ROUNDS` | Password hash rounds | 12 |
|
||||||
| `RATE_LIMIT_WINDOW_MS` | Rate limit window | 900000 (15 min) |
|
| `RATE_LIMIT_WINDOW_MS` | Rate limit window | 900000 (15 min) |
|
||||||
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | 100 |
|
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | 100 |
|
||||||
| `CORS_ORIGIN` | Allowed CORS origins | <http://localhost:5173> |
|
| `CORS_ORIGIN` | Allowed CORS origins | <http://localhost:5173> |
|
||||||
| `NODE_ENV` | Environment | development |
|
| `NODE_ENV` | Environment | development |
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|
||||||
@@ -196,14 +198,14 @@ backend-deno/
|
|||||||
|
|
||||||
## Differences from Node.js Backend
|
## Differences from Node.js Backend
|
||||||
|
|
||||||
| Feature | Node.js | Deno |
|
| Feature | Node.js | Deno |
|
||||||
|---------|---------|------|
|
| --------------- | -------------------- | -------------------- |
|
||||||
| Runtime | Node.js | Deno |
|
| Runtime | Node.js | Deno |
|
||||||
| Package Manager | npm | Built-in (JSR/npm) |
|
| Package Manager | npm | Built-in (JSR/npm) |
|
||||||
| TypeScript | Requires compilation | Native support |
|
| TypeScript | Requires compilation | Native support |
|
||||||
| Security | Manual setup | Secure by default |
|
| Security | Manual setup | Secure by default |
|
||||||
| Permissions | Full access | Explicit permissions |
|
| Permissions | Full access | Explicit permissions |
|
||||||
| Framework | Express | Oak |
|
| Framework | Express | Oak |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createPool, Pool } from "mysql2/promise";
|
import { createPool, Pool, PoolConnection } from "mysql2/promise";
|
||||||
import { load } from "@std/dotenv";
|
import { load } from "@std/dotenv";
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
@@ -33,14 +33,17 @@ class Database {
|
|||||||
async connect(): Promise<Pool> {
|
async connect(): Promise<Pool> {
|
||||||
if (!this.pool) {
|
if (!this.pool) {
|
||||||
this.pool = createPool(config);
|
this.pool = createPool(config);
|
||||||
|
|
||||||
// Test connection
|
// Test connection
|
||||||
try {
|
try {
|
||||||
const connection = await this.pool.getConnection();
|
const connection = await this.pool.getConnection();
|
||||||
console.log("✅ Database connected successfully");
|
console.log("✅ Database connected successfully");
|
||||||
connection.release();
|
connection.release();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Database connection failed:", (error as Error).message);
|
console.error(
|
||||||
|
"❌ Database connection failed:",
|
||||||
|
(error as Error).message,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,12 +63,39 @@ class Database {
|
|||||||
return rows as T;
|
return rows as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(sql: string, params?: unknown[]): Promise<{ insertId: number; affectedRows: number }> {
|
async execute(
|
||||||
|
sql: string,
|
||||||
|
params?: unknown[],
|
||||||
|
): Promise<{ insertId: number; affectedRows: number }> {
|
||||||
const pool = await this.getPool();
|
const pool = await this.getPool();
|
||||||
const [result] = await pool.execute(sql, params);
|
const [result] = await pool.execute(sql, params);
|
||||||
return result as { insertId: number; affectedRows: number };
|
return result as { insertId: number; affectedRows: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get a connection for transaction support
|
||||||
|
async getConnection(): Promise<PoolConnection> {
|
||||||
|
const pool = await this.getPool();
|
||||||
|
return await pool.getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute within a transaction
|
||||||
|
async transaction<T>(
|
||||||
|
callback: (connection: PoolConnection) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const connection = await this.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const result = await callback(connection);
|
||||||
|
await connection.commit();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
if (this.pool) {
|
if (this.pool) {
|
||||||
await this.pool.end();
|
await this.pool.end();
|
||||||
|
|||||||
@@ -5,36 +5,41 @@ await load({ export: true });
|
|||||||
export const config = {
|
export const config = {
|
||||||
// Server
|
// Server
|
||||||
PORT: parseInt(Deno.env.get("PORT") || "3000"),
|
PORT: parseInt(Deno.env.get("PORT") || "3000"),
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
DB_HOST: Deno.env.get("DB_HOST") || "localhost",
|
DB_HOST: Deno.env.get("DB_HOST") || "localhost",
|
||||||
DB_USER: Deno.env.get("DB_USER") || "root",
|
DB_USER: Deno.env.get("DB_USER") || "root",
|
||||||
DB_PASSWORD: Deno.env.get("DB_PASSWORD") || "admin123",
|
DB_PASSWORD: Deno.env.get("DB_PASSWORD") || "admin123",
|
||||||
DB_NAME: Deno.env.get("DB_NAME") || "work_allocation",
|
DB_NAME: Deno.env.get("DB_NAME") || "work_allocation",
|
||||||
DB_PORT: parseInt(Deno.env.get("DB_PORT") || "3306"),
|
DB_PORT: parseInt(Deno.env.get("DB_PORT") || "3306"),
|
||||||
|
|
||||||
// JWT - Security: Use strong secret in production
|
// JWT - Security: Use strong secret in production
|
||||||
JWT_SECRET: Deno.env.get("JWT_SECRET") || "work_alloc_jwt_secret_key_change_in_production_2024",
|
JWT_SECRET: Deno.env.get("JWT_SECRET") ||
|
||||||
|
"work_alloc_jwt_secret_key_change_in_production_2024",
|
||||||
JWT_EXPIRES_IN: Deno.env.get("JWT_EXPIRES_IN") || "7d",
|
JWT_EXPIRES_IN: Deno.env.get("JWT_EXPIRES_IN") || "7d",
|
||||||
|
|
||||||
// Security settings
|
// Security settings
|
||||||
BCRYPT_ROUNDS: parseInt(Deno.env.get("BCRYPT_ROUNDS") || "12"),
|
BCRYPT_ROUNDS: parseInt(Deno.env.get("BCRYPT_ROUNDS") || "12"),
|
||||||
RATE_LIMIT_WINDOW_MS: parseInt(Deno.env.get("RATE_LIMIT_WINDOW_MS") || "900000"), // 15 minutes
|
RATE_LIMIT_WINDOW_MS: parseInt(
|
||||||
RATE_LIMIT_MAX_REQUESTS: parseInt(Deno.env.get("RATE_LIMIT_MAX_REQUESTS") || "100"),
|
Deno.env.get("RATE_LIMIT_WINDOW_MS") || "900000",
|
||||||
|
), // 15 minutes
|
||||||
|
RATE_LIMIT_MAX_REQUESTS: parseInt(
|
||||||
|
Deno.env.get("RATE_LIMIT_MAX_REQUESTS") || "100",
|
||||||
|
),
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
CORS_ORIGIN: Deno.env.get("CORS_ORIGIN") || "http://localhost:5173",
|
CORS_ORIGIN: Deno.env.get("CORS_ORIGIN") || "http://localhost:5173",
|
||||||
|
|
||||||
// Environment
|
// Environment
|
||||||
NODE_ENV: Deno.env.get("NODE_ENV") || "development",
|
NODE_ENV: Deno.env.get("NODE_ENV") || "development",
|
||||||
|
|
||||||
isDevelopment(): boolean {
|
isDevelopment(): boolean {
|
||||||
return this.NODE_ENV === "development";
|
return this.NODE_ENV === "development";
|
||||||
},
|
},
|
||||||
|
|
||||||
isProduction(): boolean {
|
isProduction(): boolean {
|
||||||
return this.NODE_ENV === "production";
|
return this.NODE_ENV === "production";
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Application, Router } from "@oak/oak";
|
import { Application, Router } from "@oak/oak";
|
||||||
import { config } from "./config/env.ts";
|
import { config } from "./config/env.ts";
|
||||||
import { db } from "./config/database.ts";
|
import { db } from "./config/database.ts";
|
||||||
import { cors, securityHeaders, requestLogger, rateLimit } from "./middleware/security.ts";
|
import {
|
||||||
|
cors,
|
||||||
|
rateLimit,
|
||||||
|
requestLogger,
|
||||||
|
securityHeaders,
|
||||||
|
} from "./middleware/security.ts";
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
import authRoutes from "./routes/auth.ts";
|
import authRoutes from "./routes/auth.ts";
|
||||||
@@ -61,14 +66,46 @@ router.get("/health", (ctx) => {
|
|||||||
// Mount API routes
|
// Mount API routes
|
||||||
router.use("/api/auth", authRoutes.routes(), authRoutes.allowedMethods());
|
router.use("/api/auth", authRoutes.routes(), authRoutes.allowedMethods());
|
||||||
router.use("/api/users", userRoutes.routes(), userRoutes.allowedMethods());
|
router.use("/api/users", userRoutes.routes(), userRoutes.allowedMethods());
|
||||||
router.use("/api/departments", departmentRoutes.routes(), departmentRoutes.allowedMethods());
|
router.use(
|
||||||
router.use("/api/work-allocations", workAllocationRoutes.routes(), workAllocationRoutes.allowedMethods());
|
"/api/departments",
|
||||||
router.use("/api/attendance", attendanceRoutes.routes(), attendanceRoutes.allowedMethods());
|
departmentRoutes.routes(),
|
||||||
router.use("/api/contractor-rates", contractorRateRoutes.routes(), contractorRateRoutes.allowedMethods());
|
departmentRoutes.allowedMethods(),
|
||||||
router.use("/api/employee-swaps", employeeSwapRoutes.routes(), employeeSwapRoutes.allowedMethods());
|
);
|
||||||
router.use("/api/reports", reportRoutes.routes(), reportRoutes.allowedMethods());
|
router.use(
|
||||||
router.use("/api/standard-rates", standardRateRoutes.routes(), standardRateRoutes.allowedMethods());
|
"/api/work-allocations",
|
||||||
router.use("/api/activities", activityRoutes.routes(), activityRoutes.allowedMethods());
|
workAllocationRoutes.routes(),
|
||||||
|
workAllocationRoutes.allowedMethods(),
|
||||||
|
);
|
||||||
|
router.use(
|
||||||
|
"/api/attendance",
|
||||||
|
attendanceRoutes.routes(),
|
||||||
|
attendanceRoutes.allowedMethods(),
|
||||||
|
);
|
||||||
|
router.use(
|
||||||
|
"/api/contractor-rates",
|
||||||
|
contractorRateRoutes.routes(),
|
||||||
|
contractorRateRoutes.allowedMethods(),
|
||||||
|
);
|
||||||
|
router.use(
|
||||||
|
"/api/employee-swaps",
|
||||||
|
employeeSwapRoutes.routes(),
|
||||||
|
employeeSwapRoutes.allowedMethods(),
|
||||||
|
);
|
||||||
|
router.use(
|
||||||
|
"/api/reports",
|
||||||
|
reportRoutes.routes(),
|
||||||
|
reportRoutes.allowedMethods(),
|
||||||
|
);
|
||||||
|
router.use(
|
||||||
|
"/api/standard-rates",
|
||||||
|
standardRateRoutes.routes(),
|
||||||
|
standardRateRoutes.allowedMethods(),
|
||||||
|
);
|
||||||
|
router.use(
|
||||||
|
"/api/activities",
|
||||||
|
activityRoutes.routes(),
|
||||||
|
activityRoutes.allowedMethods(),
|
||||||
|
);
|
||||||
|
|
||||||
// Apply routes
|
// Apply routes
|
||||||
app.use(router.routes());
|
app.use(router.routes());
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Context, Next } from "@oak/oak";
|
import { Context, Next } from "@oak/oak";
|
||||||
import { verify, create, getNumericDate } from "djwt";
|
import { create, getNumericDate, verify } from "djwt";
|
||||||
import { config } from "../config/env.ts";
|
import { config } from "../config/env.ts";
|
||||||
import type { JWTPayload, UserRole } from "../types/index.ts";
|
import type { JWTPayload, UserRole } from "../types/index.ts";
|
||||||
|
|
||||||
@@ -12,14 +12,16 @@ const cryptoKey = await crypto.subtle.importKey(
|
|||||||
keyData,
|
keyData,
|
||||||
{ name: "HMAC", hash: "SHA-256" },
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
false,
|
false,
|
||||||
["sign", "verify"]
|
["sign", "verify"],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): Promise<string> {
|
export async function generateToken(
|
||||||
|
payload: Omit<JWTPayload, "exp" | "iat">,
|
||||||
|
): Promise<string> {
|
||||||
const expiresIn = config.JWT_EXPIRES_IN;
|
const expiresIn = config.JWT_EXPIRES_IN;
|
||||||
let expSeconds = 7 * 24 * 60 * 60; // Default 7 days
|
let expSeconds = 7 * 24 * 60 * 60; // Default 7 days
|
||||||
|
|
||||||
if (expiresIn.endsWith("d")) {
|
if (expiresIn.endsWith("d")) {
|
||||||
expSeconds = parseInt(expiresIn) * 24 * 60 * 60;
|
expSeconds = parseInt(expiresIn) * 24 * 60 * 60;
|
||||||
} else if (expiresIn.endsWith("h")) {
|
} else if (expiresIn.endsWith("h")) {
|
||||||
@@ -27,7 +29,7 @@ export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): P
|
|||||||
} else if (expiresIn.endsWith("m")) {
|
} else if (expiresIn.endsWith("m")) {
|
||||||
expSeconds = parseInt(expiresIn) * 60;
|
expSeconds = parseInt(expiresIn) * 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await create(
|
const token = await create(
|
||||||
{ alg: "HS256", typ: "JWT" },
|
{ alg: "HS256", typ: "JWT" },
|
||||||
{
|
{
|
||||||
@@ -35,9 +37,9 @@ export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): P
|
|||||||
exp: getNumericDate(expSeconds),
|
exp: getNumericDate(expSeconds),
|
||||||
iat: getNumericDate(0),
|
iat: getNumericDate(0),
|
||||||
},
|
},
|
||||||
cryptoKey
|
cryptoKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,24 +54,27 @@ export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authentication middleware
|
// Authentication middleware
|
||||||
export async function authenticateToken(ctx: Context, next: Next): Promise<void> {
|
export async function authenticateToken(
|
||||||
|
ctx: Context,
|
||||||
|
next: Next,
|
||||||
|
): Promise<void> {
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
const authHeader = ctx.request.headers.get("Authorization");
|
||||||
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
ctx.response.status = 401;
|
ctx.response.status = 401;
|
||||||
ctx.response.body = { error: "Access token required" };
|
ctx.response.body = { error: "Access token required" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await verifyToken(token);
|
const payload = await verifyToken(token);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "Invalid or expired token" };
|
ctx.response.body = { error: "Invalid or expired token" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach user to context state
|
// Attach user to context state
|
||||||
ctx.state.user = payload;
|
ctx.state.user = payload;
|
||||||
await next();
|
await next();
|
||||||
@@ -79,19 +84,19 @@ export async function authenticateToken(ctx: Context, next: Next): Promise<void>
|
|||||||
export function authorize(...roles: UserRole[]) {
|
export function authorize(...roles: UserRole[]) {
|
||||||
return async (ctx: Context, next: Next): Promise<void> => {
|
return async (ctx: Context, next: Next): Promise<void> => {
|
||||||
const user = ctx.state.user as JWTPayload | undefined;
|
const user = ctx.state.user as JWTPayload | undefined;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
ctx.response.status = 401;
|
ctx.response.status = 401;
|
||||||
ctx.response.body = { error: "Unauthorized" };
|
ctx.response.body = { error: "Unauthorized" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!roles.includes(user.role)) {
|
if (!roles.includes(user.role)) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "Insufficient permissions" };
|
ctx.response.body = { error: "Insufficient permissions" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,54 +10,57 @@ export async function rateLimit(ctx: Context, next: Next): Promise<void> {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowMs = config.RATE_LIMIT_WINDOW_MS;
|
const windowMs = config.RATE_LIMIT_WINDOW_MS;
|
||||||
const maxRequests = config.RATE_LIMIT_MAX_REQUESTS;
|
const maxRequests = config.RATE_LIMIT_MAX_REQUESTS;
|
||||||
|
|
||||||
const record = rateLimitStore.get(ip);
|
const record = rateLimitStore.get(ip);
|
||||||
|
|
||||||
if (!record || now > record.resetTime) {
|
if (!record || now > record.resetTime) {
|
||||||
rateLimitStore.set(ip, { count: 1, resetTime: now + windowMs });
|
rateLimitStore.set(ip, { count: 1, resetTime: now + windowMs });
|
||||||
} else {
|
} else {
|
||||||
record.count++;
|
record.count++;
|
||||||
|
|
||||||
if (record.count > maxRequests) {
|
if (record.count > maxRequests) {
|
||||||
ctx.response.status = 429;
|
ctx.response.status = 429;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
error: "Too many requests",
|
error: "Too many requests",
|
||||||
retryAfter: Math.ceil((record.resetTime - now) / 1000)
|
retryAfter: Math.ceil((record.resetTime - now) / 1000),
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security headers middleware
|
// Security headers middleware
|
||||||
export async function securityHeaders(ctx: Context, next: Next): Promise<void> {
|
export async function securityHeaders(ctx: Context, next: Next): Promise<void> {
|
||||||
await next();
|
await next();
|
||||||
|
|
||||||
// Prevent clickjacking
|
// Prevent clickjacking
|
||||||
ctx.response.headers.set("X-Frame-Options", "DENY");
|
ctx.response.headers.set("X-Frame-Options", "DENY");
|
||||||
|
|
||||||
// Prevent MIME type sniffing
|
// Prevent MIME type sniffing
|
||||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
|
||||||
// XSS protection
|
// XSS protection
|
||||||
ctx.response.headers.set("X-XSS-Protection", "1; mode=block");
|
ctx.response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||||
|
|
||||||
// Referrer policy
|
// Referrer policy
|
||||||
ctx.response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
ctx.response.headers.set(
|
||||||
|
"Referrer-Policy",
|
||||||
|
"strict-origin-when-cross-origin",
|
||||||
|
);
|
||||||
|
|
||||||
// Content Security Policy
|
// Content Security Policy
|
||||||
ctx.response.headers.set(
|
ctx.response.headers.set(
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Strict Transport Security (only in production with HTTPS)
|
// Strict Transport Security (only in production with HTTPS)
|
||||||
if (config.isProduction()) {
|
if (config.isProduction()) {
|
||||||
ctx.response.headers.set(
|
ctx.response.headers.set(
|
||||||
"Strict-Transport-Security",
|
"Strict-Transport-Security",
|
||||||
"max-age=31536000; includeSubDomains"
|
"max-age=31536000; includeSubDomains",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,27 +68,35 @@ export async function securityHeaders(ctx: Context, next: Next): Promise<void> {
|
|||||||
// CORS middleware
|
// CORS middleware
|
||||||
export async function cors(ctx: Context, next: Next): Promise<void> {
|
export async function cors(ctx: Context, next: Next): Promise<void> {
|
||||||
const origin = ctx.request.headers.get("Origin");
|
const origin = ctx.request.headers.get("Origin");
|
||||||
const allowedOrigins = config.CORS_ORIGIN.split(",").map(o => o.trim());
|
const allowedOrigins = config.CORS_ORIGIN.split(",").map((o) => o.trim());
|
||||||
|
|
||||||
// Check if origin is allowed
|
// Check if origin is allowed
|
||||||
if (origin && (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))) {
|
if (
|
||||||
|
origin && (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))
|
||||||
|
) {
|
||||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||||
} else if (config.isDevelopment()) {
|
} else if (config.isDevelopment()) {
|
||||||
// Allow all origins in development
|
// Allow all origins in development
|
||||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin || "*");
|
ctx.response.headers.set("Access-Control-Allow-Origin", origin || "*");
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
ctx.response.headers.set(
|
||||||
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
"Access-Control-Allow-Methods",
|
||||||
|
"GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
);
|
||||||
|
ctx.response.headers.set(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Content-Type, Authorization",
|
||||||
|
);
|
||||||
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||||
ctx.response.headers.set("Access-Control-Max-Age", "86400");
|
ctx.response.headers.set("Access-Control-Max-Age", "86400");
|
||||||
|
|
||||||
// Handle preflight requests
|
// Handle preflight requests
|
||||||
if (ctx.request.method === "OPTIONS") {
|
if (ctx.request.method === "OPTIONS") {
|
||||||
ctx.response.status = 204;
|
ctx.response.status = 204;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,19 +104,21 @@ export async function cors(ctx: Context, next: Next): Promise<void> {
|
|||||||
export async function requestLogger(ctx: Context, next: Next): Promise<void> {
|
export async function requestLogger(ctx: Context, next: Next): Promise<void> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const { method, url } = ctx.request;
|
const { method, url } = ctx.request;
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
|
||||||
const ms = Date.now() - start;
|
const ms = Date.now() - start;
|
||||||
const status = ctx.response.status;
|
const status = ctx.response.status;
|
||||||
|
|
||||||
// Color code based on status
|
// Color code based on status
|
||||||
let statusColor = "\x1b[32m"; // Green for 2xx
|
let statusColor = "\x1b[32m"; // Green for 2xx
|
||||||
if (status >= 400 && status < 500) statusColor = "\x1b[33m"; // Yellow for 4xx
|
if (status >= 400 && status < 500) statusColor = "\x1b[33m"; // Yellow for 4xx
|
||||||
if (status >= 500) statusColor = "\x1b[31m"; // Red for 5xx
|
if (status >= 500) statusColor = "\x1b[31m"; // Red for 5xx
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${new Date().toISOString()} - ${method} ${url.pathname} ${statusColor}${status}\x1b[0m ${ms}ms`
|
`${
|
||||||
|
new Date().toISOString()
|
||||||
|
} - ${method} ${url.pathname} ${statusColor}${status}\x1b[0m ${ms}ms`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,18 +138,32 @@ export function isValidEmail(email: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate password strength
|
// Validate password strength
|
||||||
export function isStrongPassword(password: string): { valid: boolean; message?: string } {
|
export function isStrongPassword(
|
||||||
|
password: string,
|
||||||
|
): { valid: boolean; message?: string } {
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
return { valid: false, message: "Password must be at least 8 characters long" };
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: "Password must be at least 8 characters long",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!/[A-Z]/.test(password)) {
|
if (!/[A-Z]/.test(password)) {
|
||||||
return { valid: false, message: "Password must contain at least one uppercase letter" };
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: "Password must contain at least one uppercase letter",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!/[a-z]/.test(password)) {
|
if (!/[a-z]/.test(password)) {
|
||||||
return { valid: false, message: "Password must contain at least one lowercase letter" };
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: "Password must contain at least one lowercase letter",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!/[0-9]/.test(password)) {
|
if (!/[0-9]/.test(password)) {
|
||||||
return { valid: false, message: "Password must contain at least one number" };
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: "Password must contain at least one number",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
const params = ctx.request.url.searchParams;
|
const params = ctx.request.url.searchParams;
|
||||||
const subDepartmentId = params.get("subDepartmentId");
|
const subDepartmentId = params.get("subDepartmentId");
|
||||||
const departmentId = params.get("departmentId");
|
const departmentId = params.get("departmentId");
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT a.id, a.sub_department_id, a.name, a.unit_of_measurement, a.created_at,
|
SELECT a.id, a.sub_department_id, a.name, a.unit_of_measurement, a.created_at,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
@@ -33,19 +33,19 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|
||||||
if (subDepartmentId) {
|
if (subDepartmentId) {
|
||||||
query += " AND a.sub_department_id = ?";
|
query += " AND a.sub_department_id = ?";
|
||||||
queryParams.push(subDepartmentId);
|
queryParams.push(subDepartmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (departmentId) {
|
if (departmentId) {
|
||||||
query += " AND sd.department_id = ?";
|
query += " AND sd.department_id = ?";
|
||||||
queryParams.push(departmentId);
|
queryParams.push(departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
query += " ORDER BY d.name, sd.name, a.name";
|
query += " ORDER BY d.name, sd.name, a.name";
|
||||||
|
|
||||||
const activities = await db.query<Activity[]>(query, queryParams);
|
const activities = await db.query<Activity[]>(query, queryParams);
|
||||||
ctx.response.body = activities;
|
ctx.response.body = activities;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -59,7 +59,7 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
router.get("/:id", authenticateToken, async (ctx) => {
|
router.get("/:id", authenticateToken, async (ctx) => {
|
||||||
try {
|
try {
|
||||||
const activityId = ctx.params.id;
|
const activityId = ctx.params.id;
|
||||||
|
|
||||||
const activities = await db.query<Activity[]>(
|
const activities = await db.query<Activity[]>(
|
||||||
`SELECT a.id, a.sub_department_id, a.name, a.unit_of_measurement, a.created_at,
|
`SELECT a.id, a.sub_department_id, a.name, a.unit_of_measurement, a.created_at,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
@@ -69,15 +69,15 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
|||||||
JOIN sub_departments sd ON a.sub_department_id = sd.id
|
JOIN sub_departments sd ON a.sub_department_id = sd.id
|
||||||
JOIN departments d ON sd.department_id = d.id
|
JOIN departments d ON sd.department_id = d.id
|
||||||
WHERE a.id = ?`,
|
WHERE a.id = ?`,
|
||||||
[activityId]
|
[activityId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activities.length === 0) {
|
if (activities.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Activity not found" };
|
ctx.response.body = { error: "Activity not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.body = activities[0];
|
ctx.response.body = activities[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get activity error:", error);
|
console.error("Get activity error:", error);
|
||||||
@@ -92,55 +92,61 @@ router.post("/", authenticateToken, async (ctx) => {
|
|||||||
const user = getCurrentUser(ctx);
|
const user = getCurrentUser(ctx);
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
const { sub_department_id, name, unit_of_measurement } = body;
|
const { sub_department_id, name, unit_of_measurement } = body;
|
||||||
|
|
||||||
if (!sub_department_id || !name) {
|
if (!sub_department_id || !name) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Sub-department ID and name are required" };
|
ctx.response.body = { error: "Sub-department ID and name are required" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the sub-department to check department ownership
|
// Get the sub-department to check department ownership
|
||||||
const subDepts = await db.query<{ department_id: number }[]>(
|
const subDepts = await db.query<{ department_id: number }[]>(
|
||||||
"SELECT department_id FROM sub_departments WHERE id = ?",
|
"SELECT department_id FROM sub_departments WHERE id = ?",
|
||||||
[sub_department_id]
|
[sub_department_id],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subDepts.length === 0) {
|
if (subDepts.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Sub-department not found" };
|
ctx.response.body = { error: "Sub-department not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subDeptDepartmentId = subDepts[0].department_id;
|
const subDeptDepartmentId = subDepts[0].department_id;
|
||||||
|
|
||||||
// Check authorization
|
// Check authorization
|
||||||
if (user.role === 'Supervisor' && user.departmentId !== subDeptDepartmentId) {
|
if (
|
||||||
|
user.role === "Supervisor" && user.departmentId !== subDeptDepartmentId
|
||||||
|
) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "You can only create activities for your own department" };
|
ctx.response.body = {
|
||||||
|
error: "You can only create activities for your own department",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role !== 'SuperAdmin' && user.role !== 'Supervisor') {
|
if (user.role !== "SuperAdmin" && user.role !== "Supervisor") {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "Unauthorized" };
|
ctx.response.body = { error: "Unauthorized" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await db.execute(
|
const result = await db.execute(
|
||||||
"INSERT INTO activities (sub_department_id, name, unit_of_measurement) VALUES (?, ?, ?)",
|
"INSERT INTO activities (sub_department_id, name, unit_of_measurement) VALUES (?, ?, ?)",
|
||||||
[sub_department_id, name, unit_of_measurement || "Per Bag"]
|
[sub_department_id, name, unit_of_measurement || "Per Bag"],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
id: result.lastInsertId,
|
id: result.insertId,
|
||||||
message: "Activity created successfully"
|
message: "Activity created successfully",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as { code?: string };
|
const err = error as { code?: string };
|
||||||
if (err.code === "ER_DUP_ENTRY") {
|
if (err.code === "ER_DUP_ENTRY") {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Activity already exists in this sub-department" };
|
ctx.response.body = {
|
||||||
|
error: "Activity already exists in this sub-department",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error("Create activity error:", error);
|
console.error("Create activity error:", error);
|
||||||
@@ -155,12 +161,12 @@ router.put("/:id", authenticateToken, async (ctx) => {
|
|||||||
const activityId = ctx.params.id;
|
const activityId = ctx.params.id;
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
const { name, unit_of_measurement } = body;
|
const { name, unit_of_measurement } = body;
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE activities SET name = ?, unit_of_measurement = ? WHERE id = ?",
|
"UPDATE activities SET name = ?, unit_of_measurement = ? WHERE id = ?",
|
||||||
[name, unit_of_measurement, activityId]
|
[name, unit_of_measurement, activityId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.body = { message: "Activity updated successfully" };
|
ctx.response.body = { message: "Activity updated successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Update activity error:", error);
|
console.error("Update activity error:", error);
|
||||||
@@ -174,39 +180,43 @@ router.delete("/:id", authenticateToken, async (ctx) => {
|
|||||||
try {
|
try {
|
||||||
const user = getCurrentUser(ctx);
|
const user = getCurrentUser(ctx);
|
||||||
const activityId = ctx.params.id;
|
const activityId = ctx.params.id;
|
||||||
|
|
||||||
// Get the activity and its sub-department to check department ownership
|
// Get the activity and its sub-department to check department ownership
|
||||||
const activities = await db.query<Activity[]>(
|
const activities = await db.query<Activity[]>(
|
||||||
`SELECT a.*, sd.department_id
|
`SELECT a.*, sd.department_id
|
||||||
FROM activities a
|
FROM activities a
|
||||||
JOIN sub_departments sd ON a.sub_department_id = sd.id
|
JOIN sub_departments sd ON a.sub_department_id = sd.id
|
||||||
WHERE a.id = ?`,
|
WHERE a.id = ?`,
|
||||||
[activityId]
|
[activityId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activities.length === 0) {
|
if (activities.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Activity not found" };
|
ctx.response.body = { error: "Activity not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activity = activities[0] as Activity & { department_id: number };
|
const activity = activities[0] as Activity & { department_id: number };
|
||||||
|
|
||||||
// Check authorization
|
// Check authorization
|
||||||
if (user.role === 'Supervisor' && user.departmentId !== activity.department_id) {
|
if (
|
||||||
|
user.role === "Supervisor" && user.departmentId !== activity.department_id
|
||||||
|
) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "You can only delete activities from your own department" };
|
ctx.response.body = {
|
||||||
|
error: "You can only delete activities from your own department",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role !== 'SuperAdmin' && user.role !== 'Supervisor') {
|
if (user.role !== "SuperAdmin" && user.role !== "Supervisor") {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "Unauthorized" };
|
ctx.response.body = { error: "Unauthorized" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.execute("DELETE FROM activities WHERE id = ?", [activityId]);
|
await db.execute("DELETE FROM activities WHERE id = ?", [activityId]);
|
||||||
|
|
||||||
ctx.response.body = { message: "Activity deleted successfully" };
|
ctx.response.body = { message: "Activity deleted successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Delete activity error:", error);
|
console.error("Delete activity error:", error);
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router, type RouterContext, type State } from "@oak/oak";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
import type { Attendance, CheckInOutRequest, User, UpdateAttendanceStatusRequest, AttendanceStatus } from "../types/index.ts";
|
authenticateToken,
|
||||||
|
authorize,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
|
import type {
|
||||||
|
Attendance,
|
||||||
|
AttendanceStatus,
|
||||||
|
CheckInOutRequest,
|
||||||
|
JWTPayload,
|
||||||
|
UpdateAttendanceStatusRequest,
|
||||||
|
User,
|
||||||
|
} from "../types/index.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
// Get all attendance records
|
// Get all attendance records
|
||||||
router.get("/", authenticateToken, async (ctx) => {
|
router.get(
|
||||||
try {
|
"/",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const params = ctx.request.url.searchParams;
|
async (
|
||||||
const employeeId = params.get("employeeId");
|
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
|
||||||
const startDate = params.get("startDate");
|
) => {
|
||||||
const endDate = params.get("endDate");
|
try {
|
||||||
const status = params.get("status");
|
const currentUser: JWTPayload = getCurrentUser(ctx);
|
||||||
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
||||||
let query = `
|
const employeeId: string | null = params.get("employeeId");
|
||||||
|
const startDate: string | null = params.get("startDate");
|
||||||
|
const endDate: string | null = params.get("endDate");
|
||||||
|
const status: string | null = params.get("status");
|
||||||
|
|
||||||
|
let query = `
|
||||||
SELECT a.*,
|
SELECT a.*,
|
||||||
e.name as employee_name, e.username as employee_username,
|
e.name as employee_name, e.username as employee_username,
|
||||||
s.name as supervisor_name,
|
s.name as supervisor_name,
|
||||||
@@ -28,53 +44,54 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
LEFT JOIN users c ON e.contractor_id = c.id
|
LEFT JOIN users c ON e.contractor_id = c.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|
||||||
// Role-based filtering
|
// Role-based filtering
|
||||||
if (currentUser.role === "Supervisor") {
|
if (currentUser.role === "Supervisor") {
|
||||||
query += " AND a.supervisor_id = ?";
|
query += " AND a.supervisor_id = ?";
|
||||||
queryParams.push(currentUser.id);
|
queryParams.push(currentUser.id);
|
||||||
} else if (currentUser.role === "Employee") {
|
} else if (currentUser.role === "Employee") {
|
||||||
query += " AND a.employee_id = ?";
|
query += " AND a.employee_id = ?";
|
||||||
queryParams.push(currentUser.id);
|
queryParams.push(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employeeId) {
|
||||||
|
query += " AND a.employee_id = ?";
|
||||||
|
queryParams.push(employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += " AND a.work_date >= ?";
|
||||||
|
queryParams.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
query += " AND a.work_date <= ?";
|
||||||
|
queryParams.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += " AND a.status = ?";
|
||||||
|
queryParams.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY a.work_date DESC, a.check_in_time DESC";
|
||||||
|
|
||||||
|
const records = await db.query<Attendance[]>(query, queryParams);
|
||||||
|
ctx.response.body = records;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get attendance error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
if (employeeId) {
|
);
|
||||||
query += " AND a.employee_id = ?";
|
|
||||||
queryParams.push(employeeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
query += " AND a.work_date >= ?";
|
|
||||||
queryParams.push(startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
query += " AND a.work_date <= ?";
|
|
||||||
queryParams.push(endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
query += " AND a.status = ?";
|
|
||||||
queryParams.push(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY a.work_date DESC, a.check_in_time DESC";
|
|
||||||
|
|
||||||
const records = await db.query<Attendance[]>(query, queryParams);
|
|
||||||
ctx.response.body = records;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get attendance error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get attendance by ID
|
// Get attendance by ID
|
||||||
router.get("/:id", authenticateToken, async (ctx) => {
|
router.get("/:id", authenticateToken, async (ctx) => {
|
||||||
try {
|
try {
|
||||||
const attendanceId = ctx.params.id;
|
const attendanceId = ctx.params.id;
|
||||||
|
|
||||||
const records = await db.query<Attendance[]>(
|
const records = await db.query<Attendance[]>(
|
||||||
`SELECT a.*,
|
`SELECT a.*,
|
||||||
e.name as employee_name, e.username as employee_username,
|
e.name as employee_name, e.username as employee_username,
|
||||||
@@ -87,15 +104,15 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
|||||||
LEFT JOIN departments d ON e.department_id = d.id
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
LEFT JOIN users c ON e.contractor_id = c.id
|
LEFT JOIN users c ON e.contractor_id = c.id
|
||||||
WHERE a.id = ?`,
|
WHERE a.id = ?`,
|
||||||
[attendanceId]
|
[attendanceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Attendance record not found" };
|
ctx.response.body = { error: "Attendance record not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.body = records[0];
|
ctx.response.body = records[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get attendance error:", error);
|
console.error("Get attendance error:", error);
|
||||||
@@ -105,245 +122,260 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check in employee (Supervisor or SuperAdmin)
|
// Check in employee (Supervisor or SuperAdmin)
|
||||||
router.post("/check-in", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.post(
|
||||||
try {
|
"/check-in",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const body = await ctx.request.body.json() as CheckInOutRequest;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const { employeeId, workDate } = body;
|
async (ctx) => {
|
||||||
|
try {
|
||||||
if (!employeeId || !workDate) {
|
const currentUser = getCurrentUser(ctx);
|
||||||
ctx.response.status = 400;
|
const body = await ctx.request.body.json() as CheckInOutRequest;
|
||||||
ctx.response.body = { error: "Employee ID and work date required" };
|
const { employeeId, workDate } = body;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify employee exists
|
|
||||||
let employeeQuery = "SELECT * FROM users WHERE id = ? AND role = ?";
|
|
||||||
const employeeParams: unknown[] = [employeeId, "Employee"];
|
|
||||||
|
|
||||||
if (currentUser.role === "Supervisor") {
|
|
||||||
employeeQuery += " AND department_id = ?";
|
|
||||||
employeeParams.push(currentUser.departmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const employees = await db.query<User[]>(employeeQuery, employeeParams);
|
|
||||||
|
|
||||||
if (employees.length === 0) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Employee not found or not in your department" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already checked in today
|
|
||||||
const existing = await db.query<Attendance[]>(
|
|
||||||
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?",
|
|
||||||
[employeeId, workDate, "CheckedIn"]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Employee already checked in today" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkInTime = new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
||||||
|
|
||||||
const result = await db.execute(
|
|
||||||
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newRecord = await db.query<Attendance[]>(
|
|
||||||
`SELECT a.*,
|
|
||||||
e.name as employee_name, e.username as employee_username,
|
|
||||||
s.name as supervisor_name,
|
|
||||||
d.name as department_name,
|
|
||||||
c.name as contractor_name
|
|
||||||
FROM attendance a
|
|
||||||
JOIN users e ON a.employee_id = e.id
|
|
||||||
JOIN users s ON a.supervisor_id = s.id
|
|
||||||
LEFT JOIN departments d ON e.department_id = d.id
|
|
||||||
LEFT JOIN users c ON e.contractor_id = c.id
|
|
||||||
WHERE a.id = ?`,
|
|
||||||
[result.insertId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = newRecord[0];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Check in error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check out employee (Supervisor or SuperAdmin)
|
if (!employeeId || !workDate) {
|
||||||
router.post("/check-out", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
ctx.response.status = 400;
|
||||||
try {
|
ctx.response.body = { error: "Employee ID and work date required" };
|
||||||
const currentUser = getCurrentUser(ctx);
|
return;
|
||||||
const body = await ctx.request.body.json() as CheckInOutRequest;
|
}
|
||||||
const { employeeId, workDate } = body;
|
|
||||||
|
|
||||||
if (!employeeId || !workDate) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Employee ID and work date required" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the check-in record
|
|
||||||
let query = "SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?";
|
|
||||||
const params: unknown[] = [employeeId, workDate, "CheckedIn"];
|
|
||||||
|
|
||||||
if (currentUser.role === "Supervisor") {
|
|
||||||
query += " AND supervisor_id = ?";
|
|
||||||
params.push(currentUser.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const records = await db.query<Attendance[]>(query, params);
|
|
||||||
|
|
||||||
if (records.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { error: "No check-in record found for today" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkOutTime = new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?",
|
|
||||||
[checkOutTime, "CheckedOut", records[0].id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedRecord = await db.query<Attendance[]>(
|
|
||||||
`SELECT a.*,
|
|
||||||
e.name as employee_name, e.username as employee_username,
|
|
||||||
s.name as supervisor_name,
|
|
||||||
d.name as department_name,
|
|
||||||
c.name as contractor_name
|
|
||||||
FROM attendance a
|
|
||||||
JOIN users e ON a.employee_id = e.id
|
|
||||||
JOIN users s ON a.supervisor_id = s.id
|
|
||||||
LEFT JOIN departments d ON e.department_id = d.id
|
|
||||||
LEFT JOIN users c ON e.contractor_id = c.id
|
|
||||||
WHERE a.id = ?`,
|
|
||||||
[records[0].id]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.body = updatedRecord[0];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Check out error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update attendance status (mark as Absent, HalfDay, Late)
|
// Verify employee exists
|
||||||
router.put("/:id/status", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
let employeeQuery = "SELECT * FROM users WHERE id = ? AND role = ?";
|
||||||
try {
|
const employeeParams: unknown[] = [employeeId, "Employee"];
|
||||||
const attendanceId = ctx.params.id;
|
|
||||||
const body = await ctx.request.body.json() as UpdateAttendanceStatusRequest;
|
|
||||||
const { status, remark } = body;
|
|
||||||
|
|
||||||
// Validate status
|
|
||||||
const validStatuses: AttendanceStatus[] = ["CheckedIn", "CheckedOut", "Absent", "HalfDay", "Late"];
|
|
||||||
if (!validStatuses.includes(status)) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Invalid status. Must be one of: CheckedIn, CheckedOut, Absent, HalfDay, Late" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if record exists
|
|
||||||
const existing = await db.query<Attendance[]>(
|
|
||||||
"SELECT * FROM attendance WHERE id = ?",
|
|
||||||
[attendanceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { error: "Attendance record not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the status
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE attendance SET status = ?, remark = ? WHERE id = ?",
|
|
||||||
[status, remark || null, attendanceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedRecord = await db.query<Attendance[]>(
|
|
||||||
`SELECT a.*,
|
|
||||||
e.name as employee_name, e.username as employee_username,
|
|
||||||
s.name as supervisor_name,
|
|
||||||
d.name as department_name,
|
|
||||||
c.name as contractor_name
|
|
||||||
FROM attendance a
|
|
||||||
JOIN users e ON a.employee_id = e.id
|
|
||||||
JOIN users s ON a.supervisor_id = s.id
|
|
||||||
LEFT JOIN departments d ON e.department_id = d.id
|
|
||||||
LEFT JOIN users c ON e.contractor_id = c.id
|
|
||||||
WHERE a.id = ?`,
|
|
||||||
[attendanceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.body = updatedRecord[0];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Update attendance status error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark employee as absent (create absent record)
|
if (currentUser.role === "Supervisor") {
|
||||||
router.post("/mark-absent", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
employeeQuery += " AND department_id = ?";
|
||||||
try {
|
employeeParams.push(currentUser.departmentId);
|
||||||
const currentUser = getCurrentUser(ctx);
|
}
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const { employeeId, workDate, remark } = body;
|
const employees = await db.query<User[]>(employeeQuery, employeeParams);
|
||||||
|
|
||||||
if (!employeeId || !workDate) {
|
if (employees.length === 0) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "Employee ID and work date required" };
|
ctx.response.body = {
|
||||||
return;
|
error: "Employee not found or not in your department",
|
||||||
}
|
};
|
||||||
|
return;
|
||||||
// Check if record already exists for this date
|
}
|
||||||
const existing = await db.query<Attendance[]>(
|
|
||||||
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ?",
|
// Check if already checked in today
|
||||||
[employeeId, workDate]
|
const existing = await db.query<Attendance[]>(
|
||||||
);
|
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?",
|
||||||
|
[employeeId, workDate, "CheckedIn"],
|
||||||
if (existing.length > 0) {
|
|
||||||
// Update existing record to Absent
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE attendance SET status = ?, remark = ? WHERE id = ?",
|
|
||||||
["Absent", remark || "Marked absent", existing[0].id]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedRecord = await db.query<Attendance[]>(
|
if (existing.length > 0) {
|
||||||
`SELECT a.*,
|
ctx.response.status = 400;
|
||||||
e.name as employee_name, e.username as employee_username,
|
ctx.response.body = { error: "Employee already checked in today" };
|
||||||
s.name as supervisor_name,
|
return;
|
||||||
d.name as department_name,
|
}
|
||||||
c.name as contractor_name
|
|
||||||
FROM attendance a
|
const checkInTime = new Date().toISOString().slice(0, 19).replace(
|
||||||
JOIN users e ON a.employee_id = e.id
|
"T",
|
||||||
JOIN users s ON a.supervisor_id = s.id
|
" ",
|
||||||
LEFT JOIN departments d ON e.department_id = d.id
|
|
||||||
LEFT JOIN users c ON e.contractor_id = c.id
|
|
||||||
WHERE a.id = ?`,
|
|
||||||
[existing[0].id]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.body = updatedRecord[0];
|
|
||||||
} else {
|
|
||||||
// Create new absent record
|
|
||||||
const result = await db.execute(
|
const result = await db.execute(
|
||||||
"INSERT INTO attendance (employee_id, supervisor_id, work_date, status, remark) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)",
|
||||||
[employeeId, currentUser.id, workDate, "Absent", remark || "Marked absent"]
|
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const newRecord = await db.query<Attendance[]>(
|
const newRecord = await db.query<Attendance[]>(
|
||||||
`SELECT a.*,
|
`SELECT a.*,
|
||||||
|
e.name as employee_name, e.username as employee_username,
|
||||||
|
s.name as supervisor_name,
|
||||||
|
d.name as department_name,
|
||||||
|
c.name as contractor_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users e ON a.employee_id = e.id
|
||||||
|
JOIN users s ON a.supervisor_id = s.id
|
||||||
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN users c ON e.contractor_id = c.id
|
||||||
|
WHERE a.id = ?`,
|
||||||
|
[result.insertId],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = newRecord[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Check in error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check out employee (Supervisor or SuperAdmin)
|
||||||
|
router.post(
|
||||||
|
"/check-out",
|
||||||
|
authenticateToken,
|
||||||
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
|
async (ctx) => {
|
||||||
|
try {
|
||||||
|
const currentUser = getCurrentUser(ctx);
|
||||||
|
const body = await ctx.request.body.json() as CheckInOutRequest;
|
||||||
|
const { employeeId, workDate } = body;
|
||||||
|
|
||||||
|
if (!employeeId || !workDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Employee ID and work date required" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the check-in record
|
||||||
|
let query =
|
||||||
|
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?";
|
||||||
|
const params: unknown[] = [employeeId, workDate, "CheckedIn"];
|
||||||
|
|
||||||
|
if (currentUser.role === "Supervisor") {
|
||||||
|
query += " AND supervisor_id = ?";
|
||||||
|
params.push(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await db.query<Attendance[]>(query, params);
|
||||||
|
|
||||||
|
if (records.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { error: "No check-in record found for today" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkOutTime = new Date().toISOString().slice(0, 19).replace(
|
||||||
|
"T",
|
||||||
|
" ",
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?",
|
||||||
|
[checkOutTime, "CheckedOut", records[0].id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedRecord = await db.query<Attendance[]>(
|
||||||
|
`SELECT a.*,
|
||||||
|
e.name as employee_name, e.username as employee_username,
|
||||||
|
s.name as supervisor_name,
|
||||||
|
d.name as department_name,
|
||||||
|
c.name as contractor_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users e ON a.employee_id = e.id
|
||||||
|
JOIN users s ON a.supervisor_id = s.id
|
||||||
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN users c ON e.contractor_id = c.id
|
||||||
|
WHERE a.id = ?`,
|
||||||
|
[records[0].id],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = updatedRecord[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Check out error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update attendance status (mark as Absent, HalfDay, Late)
|
||||||
|
router.put(
|
||||||
|
"/:id/status",
|
||||||
|
authenticateToken,
|
||||||
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
|
async (ctx) => {
|
||||||
|
try {
|
||||||
|
const attendanceId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body
|
||||||
|
.json() as UpdateAttendanceStatusRequest;
|
||||||
|
const { status, remark } = body;
|
||||||
|
|
||||||
|
// Validate status
|
||||||
|
const validStatuses: AttendanceStatus[] = [
|
||||||
|
"CheckedIn",
|
||||||
|
"CheckedOut",
|
||||||
|
"Absent",
|
||||||
|
"HalfDay",
|
||||||
|
"Late",
|
||||||
|
];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error:
|
||||||
|
"Invalid status. Must be one of: CheckedIn, CheckedOut, Absent, HalfDay, Late",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if record exists
|
||||||
|
const existing = await db.query<Attendance[]>(
|
||||||
|
"SELECT * FROM attendance WHERE id = ?",
|
||||||
|
[attendanceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { error: "Attendance record not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the status
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE attendance SET status = ?, remark = ? WHERE id = ?",
|
||||||
|
[status, remark || null, attendanceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedRecord = await db.query<Attendance[]>(
|
||||||
|
`SELECT a.*,
|
||||||
|
e.name as employee_name, e.username as employee_username,
|
||||||
|
s.name as supervisor_name,
|
||||||
|
d.name as department_name,
|
||||||
|
c.name as contractor_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users e ON a.employee_id = e.id
|
||||||
|
JOIN users s ON a.supervisor_id = s.id
|
||||||
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN users c ON e.contractor_id = c.id
|
||||||
|
WHERE a.id = ?`,
|
||||||
|
[attendanceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = updatedRecord[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update attendance status error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark employee as absent (create absent record)
|
||||||
|
router.post(
|
||||||
|
"/mark-absent",
|
||||||
|
authenticateToken,
|
||||||
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
|
async (ctx) => {
|
||||||
|
try {
|
||||||
|
const currentUser = getCurrentUser(ctx);
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { employeeId, workDate, remark } = body;
|
||||||
|
|
||||||
|
if (!employeeId || !workDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Employee ID and work date required" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if record already exists for this date
|
||||||
|
const existing = await db.query<Attendance[]>(
|
||||||
|
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ?",
|
||||||
|
[employeeId, workDate],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update existing record to Absent
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE attendance SET status = ?, remark = ? WHERE id = ?",
|
||||||
|
["Absent", remark || "Marked absent", existing[0].id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedRecord = await db.query<Attendance[]>(
|
||||||
|
`SELECT a.*,
|
||||||
e.name as employee_name, e.username as employee_username,
|
e.name as employee_name, e.username as employee_username,
|
||||||
s.name as supervisor_name,
|
s.name as supervisor_name,
|
||||||
d.name as department_name,
|
d.name as department_name,
|
||||||
@@ -354,29 +386,68 @@ router.post("/mark-absent", authenticateToken, authorize("Supervisor", "SuperAdm
|
|||||||
LEFT JOIN departments d ON e.department_id = d.id
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
LEFT JOIN users c ON e.contractor_id = c.id
|
LEFT JOIN users c ON e.contractor_id = c.id
|
||||||
WHERE a.id = ?`,
|
WHERE a.id = ?`,
|
||||||
[result.insertId]
|
[existing[0].id],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.status = 201;
|
ctx.response.body = updatedRecord[0];
|
||||||
ctx.response.body = newRecord[0];
|
} else {
|
||||||
|
// Create new absent record
|
||||||
|
const result = await db.execute(
|
||||||
|
"INSERT INTO attendance (employee_id, supervisor_id, work_date, status, remark) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
employeeId,
|
||||||
|
currentUser.id,
|
||||||
|
workDate,
|
||||||
|
"Absent",
|
||||||
|
remark || "Marked absent",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRecord = await db.query<Attendance[]>(
|
||||||
|
`SELECT a.*,
|
||||||
|
e.name as employee_name, e.username as employee_username,
|
||||||
|
s.name as supervisor_name,
|
||||||
|
d.name as department_name,
|
||||||
|
c.name as contractor_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users e ON a.employee_id = e.id
|
||||||
|
JOIN users s ON a.supervisor_id = s.id
|
||||||
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN users c ON e.contractor_id = c.id
|
||||||
|
WHERE a.id = ?`,
|
||||||
|
[result.insertId],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = newRecord[0];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Mark absent error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
},
|
||||||
console.error("Mark absent error:", error);
|
);
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get attendance summary
|
// Get attendance summary
|
||||||
router.get("/summary/stats", authenticateToken, async (ctx) => {
|
router.get(
|
||||||
try {
|
"/summary/stats",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const params = ctx.request.url.searchParams;
|
async (
|
||||||
const startDate = params.get("startDate");
|
ctx: RouterContext<
|
||||||
const endDate = params.get("endDate");
|
"/summary/stats",
|
||||||
const departmentId = params.get("departmentId");
|
Record<string | number, string | undefined>,
|
||||||
|
State
|
||||||
let query = `
|
>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const currentUser: JWTPayload = getCurrentUser(ctx);
|
||||||
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
||||||
|
const startDate: string | null = params.get("startDate");
|
||||||
|
const endDate: string | null = params.get("endDate");
|
||||||
|
const departmentId: string | null = params.get("departmentId");
|
||||||
|
|
||||||
|
let query: string = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT a.employee_id) as total_employees,
|
COUNT(DISTINCT a.employee_id) as total_employees,
|
||||||
COUNT(DISTINCT CASE WHEN a.status = 'CheckedIn' THEN a.employee_id END) as checked_in,
|
COUNT(DISTINCT CASE WHEN a.status = 'CheckedIn' THEN a.employee_id END) as checked_in,
|
||||||
@@ -387,37 +458,38 @@ router.get("/summary/stats", authenticateToken, async (ctx) => {
|
|||||||
LEFT JOIN departments d ON e.department_id = d.id
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: (number | string)[] = [];
|
||||||
|
|
||||||
if (currentUser.role === "Supervisor") {
|
if (currentUser.role === "Supervisor") {
|
||||||
query += " AND a.supervisor_id = ?";
|
query += " AND a.supervisor_id = ?";
|
||||||
queryParams.push(currentUser.id);
|
queryParams.push(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += " AND a.work_date >= ?";
|
||||||
|
queryParams.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
query += " AND a.work_date <= ?";
|
||||||
|
queryParams.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (departmentId) {
|
||||||
|
query += " AND e.department_id = ?";
|
||||||
|
queryParams.push(departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " GROUP BY d.id, d.name";
|
||||||
|
|
||||||
|
const summary = await db.query(query, queryParams);
|
||||||
|
ctx.response.body = summary;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get attendance summary error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
if (startDate) {
|
);
|
||||||
query += " AND a.work_date >= ?";
|
|
||||||
queryParams.push(startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
query += " AND a.work_date <= ?";
|
|
||||||
queryParams.push(endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (departmentId) {
|
|
||||||
query += " AND e.department_id = ?";
|
|
||||||
queryParams.push(departmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " GROUP BY d.id, d.name";
|
|
||||||
|
|
||||||
const summary = await db.query(query, queryParams);
|
|
||||||
ctx.response.body = summary;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get attendance summary error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { hash, compare, genSalt } from "bcrypt";
|
import { compare, genSalt, hash } from "bcrypt";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { config } from "../config/env.ts";
|
import { config } from "../config/env.ts";
|
||||||
|
|
||||||
// Helper function to hash password with proper salt generation
|
|
||||||
async function hashPassword(password: string): Promise<string> {
|
async function hashPassword(password: string): Promise<string> {
|
||||||
const salt = await genSalt(config.BCRYPT_ROUNDS);
|
const salt = await genSalt(config.BCRYPT_ROUNDS);
|
||||||
return await hash(password, salt);
|
return await hash(password, salt);
|
||||||
}
|
}
|
||||||
import { authenticateToken, generateToken, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
import { sanitizeInput, isValidEmail, isStrongPassword } from "../middleware/security.ts";
|
authenticateToken,
|
||||||
import type { User, LoginRequest, ChangePasswordRequest } from "../types/index.ts";
|
generateToken,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
|
import { isStrongPassword, sanitizeInput } from "../middleware/security.ts";
|
||||||
|
import type {
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
User,
|
||||||
|
} from "../types/index.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
@@ -19,41 +26,41 @@ router.post("/login", async (ctx) => {
|
|||||||
try {
|
try {
|
||||||
const body = await ctx.request.body.json() as LoginRequest;
|
const body = await ctx.request.body.json() as LoginRequest;
|
||||||
const { username, password } = body;
|
const { username, password } = body;
|
||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Username and password required" };
|
ctx.response.body = { error: "Username and password required" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize input
|
// Sanitize input
|
||||||
const sanitizedUsername = sanitizeInput(username);
|
const sanitizedUsername = sanitizeInput(username);
|
||||||
|
|
||||||
// Query user
|
// Query user
|
||||||
const users = await db.query<User[]>(
|
const users = await db.query<User[]>(
|
||||||
"SELECT * FROM users WHERE username = ? AND is_active = TRUE",
|
"SELECT * FROM users WHERE username = ? AND is_active = TRUE",
|
||||||
[sanitizedUsername]
|
[sanitizedUsername],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
// Use generic message to prevent user enumeration
|
// Use generic message to prevent user enumeration
|
||||||
ctx.response.status = 401;
|
ctx.response.status = 401;
|
||||||
ctx.response.body = { error: "Invalid credentials" };
|
ctx.response.body = { error: "Invalid credentials" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = users[0];
|
const user = users[0];
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const validPassword = await compare(password, user.password!);
|
const validPassword = await compare(password, user.password!);
|
||||||
|
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
ctx.response.status = 401;
|
ctx.response.status = 401;
|
||||||
ctx.response.body = { error: "Invalid credentials" };
|
ctx.response.body = { error: "Invalid credentials" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
const token = await generateToken({
|
const token = await generateToken({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -61,10 +68,10 @@ router.post("/login", async (ctx) => {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
departmentId: user.department_id,
|
departmentId: user.department_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return user data without password
|
// Return user data without password
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
token,
|
token,
|
||||||
user: userWithoutPassword,
|
user: userWithoutPassword,
|
||||||
@@ -80,18 +87,18 @@ router.post("/login", async (ctx) => {
|
|||||||
router.get("/me", authenticateToken, async (ctx) => {
|
router.get("/me", authenticateToken, async (ctx) => {
|
||||||
try {
|
try {
|
||||||
const currentUser = getCurrentUser(ctx);
|
const currentUser = getCurrentUser(ctx);
|
||||||
|
|
||||||
const users = await db.query<User[]>(
|
const users = await db.query<User[]>(
|
||||||
"SELECT id, username, name, email, role, department_id, contractor_id, is_active FROM users WHERE id = ?",
|
"SELECT id, username, name, email, role, department_id, contractor_id, is_active FROM users WHERE id = ?",
|
||||||
[currentUser.id]
|
[currentUser.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "User not found" };
|
ctx.response.body = { error: "User not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.body = users[0];
|
ctx.response.body = users[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get user error:", error);
|
console.error("Get user error:", error);
|
||||||
@@ -106,14 +113,14 @@ router.post("/change-password", authenticateToken, async (ctx) => {
|
|||||||
const currentUser = getCurrentUser(ctx);
|
const currentUser = getCurrentUser(ctx);
|
||||||
const body = await ctx.request.body.json() as ChangePasswordRequest;
|
const body = await ctx.request.body.json() as ChangePasswordRequest;
|
||||||
const { currentPassword, newPassword } = body;
|
const { currentPassword, newPassword } = body;
|
||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
if (!currentPassword || !newPassword) {
|
if (!currentPassword || !newPassword) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Current and new password required" };
|
ctx.response.body = { error: "Current and new password required" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate new password strength (only enforce in production or if explicitly enabled)
|
// Validate new password strength (only enforce in production or if explicitly enabled)
|
||||||
if (config.isProduction()) {
|
if (config.isProduction()) {
|
||||||
const passwordCheck = isStrongPassword(newPassword);
|
const passwordCheck = isStrongPassword(newPassword);
|
||||||
@@ -127,37 +134,37 @@ router.post("/change-password", authenticateToken, async (ctx) => {
|
|||||||
ctx.response.body = { error: "Password must be at least 6 characters" };
|
ctx.response.body = { error: "Password must be at least 6 characters" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current password hash
|
// Get current password hash
|
||||||
const users = await db.query<User[]>(
|
const users = await db.query<User[]>(
|
||||||
"SELECT password FROM users WHERE id = ?",
|
"SELECT password FROM users WHERE id = ?",
|
||||||
[currentUser.id]
|
[currentUser.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "User not found" };
|
ctx.response.body = { error: "User not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify current password
|
// Verify current password
|
||||||
const validPassword = await compare(currentPassword, users[0].password!);
|
const validPassword = await compare(currentPassword, users[0].password!);
|
||||||
|
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
ctx.response.status = 401;
|
ctx.response.status = 401;
|
||||||
ctx.response.body = { error: "Current password is incorrect" };
|
ctx.response.body = { error: "Current password is incorrect" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash new password with configured rounds
|
// Hash new password with configured rounds
|
||||||
const hashedPassword = await hashPassword(newPassword);
|
const hashedPassword = await hashPassword(newPassword);
|
||||||
|
|
||||||
// Update password
|
// Update password
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE users SET password = ? WHERE id = ?",
|
"UPDATE users SET password = ? WHERE id = ?",
|
||||||
[hashedPassword, currentUser.id]
|
[hashedPassword, currentUser.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.body = { message: "Password changed successfully" };
|
ctx.response.body = { message: "Password changed successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Change password error:", error);
|
console.error("Change password error:", error);
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router, type RouterContext, type State } from "@oak/oak";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
|
authenticateToken,
|
||||||
|
authorize,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
import { sanitizeInput } from "../middleware/security.ts";
|
import { sanitizeInput } from "../middleware/security.ts";
|
||||||
import type { ContractorRate, CreateContractorRateRequest, User } from "../types/index.ts";
|
import type {
|
||||||
|
ContractorRate,
|
||||||
|
CreateContractorRateRequest,
|
||||||
|
User,
|
||||||
|
} from "../types/index.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
// Get contractor rates
|
// Get contractor rates
|
||||||
router.get("/", authenticateToken, async (ctx) => {
|
router.get(
|
||||||
try {
|
"/",
|
||||||
const params = ctx.request.url.searchParams;
|
authenticateToken,
|
||||||
const contractorId = params.get("contractorId");
|
async (
|
||||||
const subDepartmentId = params.get("subDepartmentId");
|
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
|
||||||
|
) => {
|
||||||
let query = `
|
try {
|
||||||
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
||||||
|
const contractorId: string | null = params.get("contractorId");
|
||||||
|
const subDepartmentId: string | null = params.get("subDepartmentId");
|
||||||
|
|
||||||
|
let query: string = `
|
||||||
SELECT cr.*,
|
SELECT cr.*,
|
||||||
u.name as contractor_name, u.username as contractor_username,
|
u.name as contractor_name, u.username as contractor_username,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
@@ -26,37 +39,47 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|
||||||
if (contractorId) {
|
if (contractorId) {
|
||||||
query += " AND cr.contractor_id = ?";
|
query += " AND cr.contractor_id = ?";
|
||||||
queryParams.push(contractorId);
|
queryParams.push(contractorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subDepartmentId) {
|
||||||
|
query += " AND cr.sub_department_id = ?";
|
||||||
|
queryParams.push(subDepartmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY cr.effective_date DESC, cr.created_at DESC";
|
||||||
|
|
||||||
|
const rates = await db.query<ContractorRate[]>(query, queryParams);
|
||||||
|
ctx.response.body = rates;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get contractor rates error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
if (subDepartmentId) {
|
);
|
||||||
query += " AND cr.sub_department_id = ?";
|
|
||||||
queryParams.push(subDepartmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY cr.effective_date DESC, cr.created_at DESC";
|
|
||||||
|
|
||||||
const rates = await db.query<ContractorRate[]>(query, queryParams);
|
|
||||||
ctx.response.body = rates;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get contractor rates error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get current rate for a contractor + sub-department combination
|
// Get current rate for a contractor + sub-department combination
|
||||||
router.get("/contractor/:contractorId/current", authenticateToken, async (ctx) => {
|
router.get(
|
||||||
try {
|
"/contractor/:contractorId/current",
|
||||||
const contractorId = ctx.params.contractorId;
|
authenticateToken,
|
||||||
const params = ctx.request.url.searchParams;
|
async (
|
||||||
const subDepartmentId = params.get("subDepartmentId");
|
ctx: RouterContext<
|
||||||
|
"/contractor/:contractorId/current",
|
||||||
let query = `
|
{ contractorId: string } & Record<string | number, string | undefined>,
|
||||||
|
State
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const contractorId = ctx.params.contractorId;
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const subDepartmentId = params.get("subDepartmentId");
|
||||||
|
|
||||||
|
let query: string = `
|
||||||
SELECT cr.*,
|
SELECT cr.*,
|
||||||
u.name as contractor_name, u.username as contractor_username,
|
u.name as contractor_name, u.username as contractor_username,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
@@ -67,72 +90,92 @@ router.get("/contractor/:contractorId/current", authenticateToken, async (ctx) =
|
|||||||
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
||||||
WHERE cr.contractor_id = ?
|
WHERE cr.contractor_id = ?
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [contractorId];
|
const queryParams: unknown[] = [contractorId];
|
||||||
|
|
||||||
if (subDepartmentId) {
|
if (subDepartmentId) {
|
||||||
query += " AND cr.sub_department_id = ?";
|
query += " AND cr.sub_department_id = ?";
|
||||||
queryParams.push(subDepartmentId);
|
queryParams.push(subDepartmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY cr.effective_date DESC LIMIT 1";
|
||||||
|
|
||||||
|
const rates = await db.query<ContractorRate[]>(query, queryParams);
|
||||||
|
|
||||||
|
if (rates.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { error: "No rate found for contractor" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = rates[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get current rate error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
query += " ORDER BY cr.effective_date DESC LIMIT 1";
|
);
|
||||||
|
|
||||||
const rates = await db.query<ContractorRate[]>(query, queryParams);
|
|
||||||
|
|
||||||
if (rates.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { error: "No rate found for contractor" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.response.body = rates[0];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get current rate error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set contractor rate (Supervisor or SuperAdmin)
|
// Set contractor rate (Supervisor or SuperAdmin)
|
||||||
router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.post(
|
||||||
try {
|
"/",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const body = await ctx.request.body.json() as CreateContractorRateRequest;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const { contractorId, subDepartmentId, activity, rate, effectiveDate } = body;
|
async (
|
||||||
|
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
|
||||||
if (!contractorId || !rate || !effectiveDate) {
|
) => {
|
||||||
ctx.response.status = 400;
|
try {
|
||||||
ctx.response.body = { error: "Missing required fields (contractorId, rate, effectiveDate)" };
|
const currentUser = getCurrentUser(ctx);
|
||||||
return;
|
const body = await ctx.request.body.json() as CreateContractorRateRequest;
|
||||||
}
|
const { contractorId, subDepartmentId, activity, rate, effectiveDate } =
|
||||||
|
body;
|
||||||
// Verify contractor exists
|
|
||||||
const contractors = await db.query<User[]>(
|
if (!contractorId || !rate || !effectiveDate) {
|
||||||
"SELECT * FROM users WHERE id = ? AND role = ?",
|
ctx.response.status = 400;
|
||||||
[contractorId, "Contractor"]
|
ctx.response.body = {
|
||||||
);
|
error: "Missing required fields (contractorId, rate, effectiveDate)",
|
||||||
|
};
|
||||||
if (contractors.length === 0) {
|
return;
|
||||||
ctx.response.status = 404;
|
}
|
||||||
ctx.response.body = { error: "Contractor not found" };
|
|
||||||
return;
|
// Verify contractor exists
|
||||||
}
|
const contractors = await db.query<User[]>(
|
||||||
|
"SELECT * FROM users WHERE id = ? AND role = ?",
|
||||||
// Supervisors can only set rates for contractors in their department
|
[contractorId, "Contractor"],
|
||||||
if (currentUser.role === "Supervisor" && contractors[0].department_id !== currentUser.departmentId) {
|
);
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Contractor not in your department" };
|
if (contractors.length === 0) {
|
||||||
return;
|
ctx.response.status = 404;
|
||||||
}
|
ctx.response.body = { error: "Contractor not found" };
|
||||||
|
return;
|
||||||
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
|
}
|
||||||
|
|
||||||
const result = await db.execute(
|
// Supervisors can only set rates for contractors in their department
|
||||||
"INSERT INTO contractor_rates (contractor_id, sub_department_id, activity, rate, effective_date) VALUES (?, ?, ?, ?, ?)",
|
if (
|
||||||
[contractorId, subDepartmentId || null, sanitizedActivity, rate, effectiveDate]
|
currentUser.role === "Supervisor" &&
|
||||||
);
|
contractors[0].department_id !== currentUser.departmentId
|
||||||
|
) {
|
||||||
const newRate = await db.query<ContractorRate[]>(
|
ctx.response.status = 403;
|
||||||
`SELECT cr.*,
|
ctx.response.body = { error: "Contractor not in your department" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
|
||||||
|
|
||||||
|
const result: { insertId: number; affectedRows: number } = await db
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO contractor_rates (contractor_id, sub_department_id, activity, rate, effective_date) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
contractorId,
|
||||||
|
subDepartmentId || null,
|
||||||
|
sanitizedActivity,
|
||||||
|
rate,
|
||||||
|
effectiveDate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRate: ContractorRate[] = await db.query<ContractorRate[]>(
|
||||||
|
`SELECT cr.*,
|
||||||
u.name as contractor_name, u.username as contractor_username,
|
u.name as contractor_name, u.username as contractor_username,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
a.unit_of_measurement
|
a.unit_of_measurement
|
||||||
@@ -141,67 +184,82 @@ router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async
|
|||||||
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
|
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
|
||||||
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
||||||
WHERE cr.id = ?`,
|
WHERE cr.id = ?`,
|
||||||
[result.insertId]
|
[result.insertId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = newRate[0];
|
ctx.response.body = newRate[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Set contractor rate error:", error);
|
console.error("Set contractor rate error:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
ctx.response.body = { error: "Internal server error" };
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update contractor rate
|
// Update contractor rate
|
||||||
router.put("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.put(
|
||||||
try {
|
"/:id",
|
||||||
const rateId = ctx.params.id;
|
authenticateToken,
|
||||||
const body = await ctx.request.body.json() as { rate?: number; activity?: string; effectiveDate?: string };
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const { rate, activity, effectiveDate } = body;
|
async (
|
||||||
|
ctx: RouterContext<
|
||||||
const existing = await db.query<ContractorRate[]>(
|
"/:id",
|
||||||
"SELECT * FROM contractor_rates WHERE id = ?",
|
{ id: string } & Record<string | number, string | undefined>,
|
||||||
[rateId]
|
State
|
||||||
);
|
>,
|
||||||
|
) => {
|
||||||
if (existing.length === 0) {
|
try {
|
||||||
ctx.response.status = 404;
|
const rateId = ctx.params.id;
|
||||||
ctx.response.body = { error: "Rate not found" };
|
const body = await ctx.request.body.json() as {
|
||||||
return;
|
rate?: number;
|
||||||
}
|
activity?: string;
|
||||||
|
effectiveDate?: string;
|
||||||
const updates: string[] = [];
|
};
|
||||||
const params: unknown[] = [];
|
const { rate, activity, effectiveDate } = body;
|
||||||
|
|
||||||
if (rate !== undefined) {
|
const existing = await db.query<ContractorRate[]>(
|
||||||
updates.push("rate = ?");
|
"SELECT * FROM contractor_rates WHERE id = ?",
|
||||||
params.push(rate);
|
[rateId],
|
||||||
}
|
);
|
||||||
if (activity !== undefined) {
|
|
||||||
updates.push("activity = ?");
|
if (existing.length === 0) {
|
||||||
params.push(sanitizeInput(activity));
|
ctx.response.status = 404;
|
||||||
}
|
ctx.response.body = { error: "Rate not found" };
|
||||||
if (effectiveDate !== undefined) {
|
return;
|
||||||
updates.push("effective_date = ?");
|
}
|
||||||
params.push(effectiveDate);
|
|
||||||
}
|
const updates: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
if (updates.length === 0) {
|
|
||||||
ctx.response.status = 400;
|
if (rate !== undefined) {
|
||||||
ctx.response.body = { error: "No fields to update" };
|
updates.push("rate = ?");
|
||||||
return;
|
params.push(rate);
|
||||||
}
|
}
|
||||||
|
if (activity !== undefined) {
|
||||||
params.push(rateId);
|
updates.push("activity = ?");
|
||||||
|
params.push(sanitizeInput(activity));
|
||||||
await db.execute(
|
}
|
||||||
`UPDATE contractor_rates SET ${updates.join(", ")} WHERE id = ?`,
|
if (effectiveDate !== undefined) {
|
||||||
params
|
updates.push("effective_date = ?");
|
||||||
);
|
params.push(effectiveDate);
|
||||||
|
}
|
||||||
const updatedRate = await db.query<ContractorRate[]>(
|
|
||||||
`SELECT cr.*,
|
if (updates.length === 0) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "No fields to update" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(rateId);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE contractor_rates SET ${updates.join(", ")} WHERE id = ?`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedRate = await db.query<ContractorRate[]>(
|
||||||
|
`SELECT cr.*,
|
||||||
u.name as contractor_name, u.username as contractor_username,
|
u.name as contractor_name, u.username as contractor_username,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
a.unit_of_measurement
|
a.unit_of_measurement
|
||||||
@@ -210,40 +268,52 @@ router.put("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), asy
|
|||||||
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
|
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
|
||||||
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
||||||
WHERE cr.id = ?`,
|
WHERE cr.id = ?`,
|
||||||
[rateId]
|
[rateId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.body = updatedRate[0];
|
ctx.response.body = updatedRate[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Update contractor rate error:", error);
|
console.error("Update contractor rate error:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
ctx.response.body = { error: "Internal server error" };
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Delete contractor rate
|
// Delete contractor rate
|
||||||
router.delete("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.delete(
|
||||||
try {
|
"/:id",
|
||||||
const rateId = ctx.params.id;
|
authenticateToken,
|
||||||
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const existing = await db.query<ContractorRate[]>(
|
async (
|
||||||
"SELECT * FROM contractor_rates WHERE id = ?",
|
ctx: RouterContext<
|
||||||
[rateId]
|
"/:id",
|
||||||
);
|
{ id: string } & Record<string | number, string | undefined>,
|
||||||
|
State
|
||||||
if (existing.length === 0) {
|
>,
|
||||||
ctx.response.status = 404;
|
) => {
|
||||||
ctx.response.body = { error: "Rate not found" };
|
try {
|
||||||
return;
|
const rateId = ctx.params.id;
|
||||||
|
|
||||||
|
const existing = await db.query<ContractorRate[]>(
|
||||||
|
"SELECT * FROM contractor_rates WHERE id = ?",
|
||||||
|
[rateId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { error: "Rate not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute("DELETE FROM contractor_rates WHERE id = ?", [rateId]);
|
||||||
|
ctx.response.body = { message: "Rate deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete contractor rate error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
await db.execute("DELETE FROM contractor_rates WHERE id = ?", [rateId]);
|
);
|
||||||
ctx.response.body = { message: "Rate deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete contractor rate error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { type Context, Router, type RouterContext } from "@oak/oak";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
|
authenticateToken,
|
||||||
|
authorize,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
import { sanitizeInput } from "../middleware/security.ts";
|
import { sanitizeInput } from "../middleware/security.ts";
|
||||||
import type { Department, SubDepartment } from "../types/index.ts";
|
import type { Department, SubDepartment } from "../types/index.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
// Get all departments
|
// Get all departments
|
||||||
router.get("/", authenticateToken, async (ctx) => {
|
router.get("/", authenticateToken, async (ctx: Context) => {
|
||||||
try {
|
try {
|
||||||
const departments = await db.query<Department[]>(
|
const departments = await db.query<Department[]>(
|
||||||
"SELECT * FROM departments ORDER BY name"
|
"SELECT * FROM departments ORDER BY name",
|
||||||
);
|
);
|
||||||
ctx.response.body = departments;
|
ctx.response.body = departments;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -21,21 +25,21 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get department by ID
|
// Get department by ID
|
||||||
router.get("/:id", authenticateToken, async (ctx) => {
|
router.get("/:id", authenticateToken, async (ctx: RouterContext<"/:id">) => {
|
||||||
try {
|
try {
|
||||||
const deptId = ctx.params.id;
|
const deptId = ctx.params.id;
|
||||||
|
|
||||||
const departments = await db.query<Department[]>(
|
const departments = await db.query<Department[]>(
|
||||||
"SELECT * FROM departments WHERE id = ?",
|
"SELECT * FROM departments WHERE id = ?",
|
||||||
[deptId]
|
[deptId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (departments.length === 0) {
|
if (departments.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Department not found" };
|
ctx.response.body = { error: "Department not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.body = departments[0];
|
ctx.response.body = departments[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get department error:", error);
|
console.error("Get department error:", error);
|
||||||
@@ -44,70 +48,101 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get all sub-departments (for reporting/filtering)
|
||||||
|
router.get(
|
||||||
|
"/sub-departments/all",
|
||||||
|
authenticateToken,
|
||||||
|
async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const subDepartments = await db.query<SubDepartment[]>(
|
||||||
|
"SELECT sd.*, d.name as department_name FROM sub_departments sd LEFT JOIN departments d ON sd.department_id = d.id ORDER BY d.name, sd.name",
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = subDepartments;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get all sub-departments error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get sub-departments by department ID
|
// Get sub-departments by department ID
|
||||||
router.get("/:id/sub-departments", authenticateToken, async (ctx) => {
|
router.get(
|
||||||
try {
|
"/:id/sub-departments",
|
||||||
const deptId = ctx.params.id;
|
authenticateToken,
|
||||||
|
async (ctx: RouterContext<"/:id/sub-departments">) => {
|
||||||
const subDepartments = await db.query<SubDepartment[]>(
|
try {
|
||||||
"SELECT * FROM sub_departments WHERE department_id = ? ORDER BY name",
|
const deptId = ctx.params.id;
|
||||||
[deptId]
|
|
||||||
);
|
const subDepartments = await db.query<SubDepartment[]>(
|
||||||
|
"SELECT * FROM sub_departments WHERE department_id = ? ORDER BY name",
|
||||||
ctx.response.body = subDepartments;
|
[deptId],
|
||||||
} catch (error) {
|
);
|
||||||
console.error("Get sub-departments error:", error);
|
|
||||||
ctx.response.status = 500;
|
ctx.response.body = subDepartments;
|
||||||
ctx.response.body = { error: "Internal server error" };
|
} catch (error) {
|
||||||
}
|
console.error("Get sub-departments error:", error);
|
||||||
});
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Create department (SuperAdmin only)
|
// Create department (SuperAdmin only)
|
||||||
router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
router.post(
|
||||||
try {
|
"/",
|
||||||
const body = await ctx.request.body.json() as { name: string };
|
authenticateToken,
|
||||||
const { name } = body;
|
authorize("SuperAdmin"),
|
||||||
|
async (ctx: Context) => {
|
||||||
if (!name) {
|
try {
|
||||||
ctx.response.status = 400;
|
const body = await ctx.request.body.json() as { name: string };
|
||||||
ctx.response.body = { error: "Department name required" };
|
const { name } = body;
|
||||||
return;
|
|
||||||
|
if (!name) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Department name required" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedName = sanitizeInput(name);
|
||||||
|
|
||||||
|
const result = await db.execute(
|
||||||
|
"INSERT INTO departments (name) VALUES (?)",
|
||||||
|
[sanitizedName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newDepartment = await db.query<Department[]>(
|
||||||
|
"SELECT * FROM departments WHERE id = ?",
|
||||||
|
[result.insertId],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = newDepartment[0];
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as { code?: string };
|
||||||
|
if (err.code === "ER_DUP_ENTRY") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Department already exists" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Create department error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const sanitizedName = sanitizeInput(name);
|
);
|
||||||
|
|
||||||
const result = await db.execute(
|
|
||||||
"INSERT INTO departments (name) VALUES (?)",
|
|
||||||
[sanitizedName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newDepartment = await db.query<Department[]>(
|
|
||||||
"SELECT * FROM departments WHERE id = ?",
|
|
||||||
[result.insertId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = newDepartment[0];
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as { code?: string };
|
|
||||||
if (err.code === "ER_DUP_ENTRY") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Department already exists" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error("Create department error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create sub-department (SuperAdmin or Supervisor for their own department)
|
// Create sub-department (SuperAdmin or Supervisor for their own department)
|
||||||
router.post("/sub-departments", authenticateToken, async (ctx) => {
|
router.post("/sub-departments", authenticateToken, async (ctx: Context) => {
|
||||||
try {
|
try {
|
||||||
const user = getCurrentUser(ctx);
|
const user = getCurrentUser(ctx);
|
||||||
const body = await ctx.request.body.json() as { department_id: number; name: string };
|
const body = await ctx.request.body.json() as {
|
||||||
|
department_id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
const { department_id, name } = body;
|
const { department_id, name } = body;
|
||||||
|
|
||||||
if (!name || !department_id) {
|
if (!name || !department_id) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Department ID and name are required" };
|
ctx.response.body = { error: "Department ID and name are required" };
|
||||||
@@ -115,37 +150,41 @@ router.post("/sub-departments", authenticateToken, async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check authorization
|
// Check authorization
|
||||||
if (user.role === 'Supervisor' && user.departmentId !== department_id) {
|
if (user.role === "Supervisor" && user.departmentId !== department_id) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "You can only create sub-departments for your own department" };
|
ctx.response.body = {
|
||||||
|
error: "You can only create sub-departments for your own department",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role !== 'SuperAdmin' && user.role !== 'Supervisor') {
|
if (user.role !== "SuperAdmin" && user.role !== "Supervisor") {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "Unauthorized" };
|
ctx.response.body = { error: "Unauthorized" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedName = sanitizeInput(name);
|
const sanitizedName = sanitizeInput(name);
|
||||||
|
|
||||||
const result = await db.execute(
|
const result = await db.execute(
|
||||||
"INSERT INTO sub_departments (department_id, name) VALUES (?, ?)",
|
"INSERT INTO sub_departments (department_id, name) VALUES (?, ?)",
|
||||||
[department_id, sanitizedName]
|
[department_id, sanitizedName],
|
||||||
);
|
);
|
||||||
|
|
||||||
const newSubDepartment = await db.query<SubDepartment[]>(
|
const newSubDepartment = await db.query<SubDepartment[]>(
|
||||||
"SELECT * FROM sub_departments WHERE id = ?",
|
"SELECT * FROM sub_departments WHERE id = ?",
|
||||||
[result.lastInsertId]
|
[result.insertId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = newSubDepartment[0];
|
ctx.response.body = newSubDepartment[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as { code?: string };
|
const err = error as { code?: string };
|
||||||
if (err.code === "ER_DUP_ENTRY") {
|
if (err.code === "ER_DUP_ENTRY") {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Sub-department already exists in this department" };
|
ctx.response.body = {
|
||||||
|
error: "Sub-department already exists in this department",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error("Create sub-department error:", error);
|
console.error("Create sub-department error:", error);
|
||||||
@@ -155,90 +194,108 @@ router.post("/sub-departments", authenticateToken, async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete sub-department (SuperAdmin or Supervisor for their own department)
|
// Delete sub-department (SuperAdmin or Supervisor for their own department)
|
||||||
router.delete("/sub-departments/:id", authenticateToken, async (ctx) => {
|
router.delete(
|
||||||
try {
|
"/sub-departments/:id",
|
||||||
const user = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const subDeptId = ctx.params.id;
|
async (ctx: RouterContext<"/sub-departments/:id">) => {
|
||||||
|
try {
|
||||||
// Get the sub-department to check department ownership
|
const user = getCurrentUser(ctx);
|
||||||
const subDepts = await db.query<SubDepartment[]>(
|
const subDeptId = ctx.params.id;
|
||||||
"SELECT * FROM sub_departments WHERE id = ?",
|
|
||||||
[subDeptId]
|
// Get the sub-department to check department ownership
|
||||||
);
|
const subDepts = await db.query<SubDepartment[]>(
|
||||||
|
"SELECT * FROM sub_departments WHERE id = ?",
|
||||||
if (subDepts.length === 0) {
|
[subDeptId],
|
||||||
ctx.response.status = 404;
|
);
|
||||||
ctx.response.body = { error: "Sub-department not found" };
|
|
||||||
return;
|
if (subDepts.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { error: "Sub-department not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subDept = subDepts[0];
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
if (
|
||||||
|
user.role === "Supervisor" &&
|
||||||
|
user.departmentId !== subDept.department_id
|
||||||
|
) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: "You can only delete sub-departments from your own department",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== "SuperAdmin" && user.role !== "Supervisor") {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = { error: "Unauthorized" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete associated activities first (cascade should handle this, but being explicit)
|
||||||
|
await db.execute("DELETE FROM activities WHERE sub_department_id = ?", [
|
||||||
|
subDeptId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete the sub-department
|
||||||
|
await db.execute("DELETE FROM sub_departments WHERE id = ?", [subDeptId]);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Sub-department deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete sub-department error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const subDept = subDepts[0];
|
);
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
if (user.role === 'Supervisor' && user.departmentId !== subDept.department_id) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "You can only delete sub-departments from your own department" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role !== 'SuperAdmin' && user.role !== 'Supervisor') {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Unauthorized" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete associated activities first (cascade should handle this, but being explicit)
|
|
||||||
await db.execute("DELETE FROM activities WHERE sub_department_id = ?", [subDeptId]);
|
|
||||||
|
|
||||||
// Delete the sub-department
|
|
||||||
await db.execute("DELETE FROM sub_departments WHERE id = ?", [subDeptId]);
|
|
||||||
|
|
||||||
ctx.response.body = { message: "Sub-department deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete sub-department error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Legacy route for creating sub-department under specific department (SuperAdmin only)
|
// Legacy route for creating sub-department under specific department (SuperAdmin only)
|
||||||
router.post("/:id/sub-departments", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
router.post(
|
||||||
try {
|
"/:id/sub-departments",
|
||||||
const deptId = ctx.params.id;
|
authenticateToken,
|
||||||
const body = await ctx.request.body.json() as { name: string };
|
authorize("SuperAdmin"),
|
||||||
const { name } = body;
|
async (ctx: RouterContext<"/:id/sub-departments">) => {
|
||||||
|
try {
|
||||||
if (!name) {
|
const deptId: string | number = ctx.params.id;
|
||||||
ctx.response.status = 400;
|
const body = await ctx.request.body.json() as { name: string };
|
||||||
ctx.response.body = { error: "Name is required" };
|
const { name } = body;
|
||||||
return;
|
|
||||||
|
if (!name) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Name is required" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedName = sanitizeInput(name);
|
||||||
|
|
||||||
|
const result = await db.execute(
|
||||||
|
"INSERT INTO sub_departments (department_id, name) VALUES (?, ?)",
|
||||||
|
[deptId, sanitizedName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSubDepartment = await db.query<SubDepartment[]>(
|
||||||
|
"SELECT * FROM sub_departments WHERE id = ?",
|
||||||
|
[result.insertId],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = newSubDepartment[0];
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as { code?: string };
|
||||||
|
if (err.code === "ER_DUP_ENTRY") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: "Sub-department already exists in this department",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Create sub-department error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const sanitizedName = sanitizeInput(name);
|
);
|
||||||
|
|
||||||
const result = await db.execute(
|
|
||||||
"INSERT INTO sub_departments (department_id, name) VALUES (?, ?)",
|
|
||||||
[deptId, sanitizedName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newSubDepartment = await db.query<SubDepartment[]>(
|
|
||||||
"SELECT * FROM sub_departments WHERE id = ?",
|
|
||||||
[result.lastInsertId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = newSubDepartment[0];
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as { code?: string };
|
|
||||||
if (err.code === "ER_DUP_ENTRY") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Sub-department already exists in this department" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error("Create sub-department error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router, type RouterContext, type State } from "@oak/oak";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
import type { EmployeeSwap, CreateSwapRequest, User } from "../types/index.ts";
|
authenticateToken,
|
||||||
|
authorize,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
|
import type { CreateSwapRequest, EmployeeSwap, User } from "../types/index.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
// Get all employee swaps (SuperAdmin only)
|
// Get all employee swaps (SuperAdmin only)
|
||||||
router.get("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
router.get(
|
||||||
try {
|
"/",
|
||||||
const params = ctx.request.url.searchParams;
|
authenticateToken,
|
||||||
const status = params.get("status");
|
authorize("SuperAdmin"),
|
||||||
const employeeId = params.get("employeeId");
|
async (
|
||||||
const startDate = params.get("startDate");
|
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
|
||||||
const endDate = params.get("endDate");
|
) => {
|
||||||
|
try {
|
||||||
let query = `
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
||||||
|
const status: string | null = params.get("status");
|
||||||
|
const employeeId: string | null = params.get("employeeId");
|
||||||
|
const startDate: string | null = params.get("startDate");
|
||||||
|
const endDate: string | null = params.get("endDate");
|
||||||
|
|
||||||
|
let query = `
|
||||||
SELECT es.*,
|
SELECT es.*,
|
||||||
e.name as employee_name,
|
e.name as employee_name,
|
||||||
od.name as original_department_name,
|
od.name as original_department_name,
|
||||||
@@ -31,46 +41,57 @@ router.get("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
|||||||
JOIN users sb ON es.swapped_by = sb.id
|
JOIN users sb ON es.swapped_by = sb.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
query += " AND es.status = ?";
|
query += " AND es.status = ?";
|
||||||
queryParams.push(status);
|
queryParams.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employeeId) {
|
||||||
|
query += " AND es.employee_id = ?";
|
||||||
|
queryParams.push(employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += " AND es.swap_date >= ?";
|
||||||
|
queryParams.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
query += " AND es.swap_date <= ?";
|
||||||
|
queryParams.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY es.created_at DESC";
|
||||||
|
|
||||||
|
const swaps = await db.query<EmployeeSwap[]>(query, queryParams);
|
||||||
|
ctx.response.body = swaps;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get employee swaps error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
if (employeeId) {
|
);
|
||||||
query += " AND es.employee_id = ?";
|
|
||||||
queryParams.push(employeeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
query += " AND es.swap_date >= ?";
|
|
||||||
queryParams.push(startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
query += " AND es.swap_date <= ?";
|
|
||||||
queryParams.push(endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY es.created_at DESC";
|
|
||||||
|
|
||||||
const swaps = await db.query<EmployeeSwap[]>(query, queryParams);
|
|
||||||
ctx.response.body = swaps;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get employee swaps error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get swap by ID
|
// Get swap by ID
|
||||||
router.get("/:id", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
router.get(
|
||||||
try {
|
"/:id",
|
||||||
const swapId = ctx.params.id;
|
authenticateToken,
|
||||||
|
authorize("SuperAdmin"),
|
||||||
const swaps = await db.query<EmployeeSwap[]>(
|
async (
|
||||||
`SELECT es.*,
|
ctx: RouterContext<
|
||||||
|
"/:id",
|
||||||
|
{ id: string } & Record<string | number, string | undefined>,
|
||||||
|
State
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const swapId = ctx.params.id;
|
||||||
|
|
||||||
|
const swaps = await db.query<EmployeeSwap[]>(
|
||||||
|
`SELECT es.*,
|
||||||
e.name as employee_name,
|
e.name as employee_name,
|
||||||
od.name as original_department_name,
|
od.name as original_department_name,
|
||||||
td.name as target_department_name,
|
td.name as target_department_name,
|
||||||
@@ -85,45 +106,49 @@ router.get("/:id", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
|||||||
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
||||||
JOIN users sb ON es.swapped_by = sb.id
|
JOIN users sb ON es.swapped_by = sb.id
|
||||||
WHERE es.id = ?`,
|
WHERE es.id = ?`,
|
||||||
[swapId]
|
[swapId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (swaps.length === 0) {
|
if (swaps.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Swap record not found" };
|
ctx.response.body = { error: "Swap record not found" };
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = swaps[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get swap error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
ctx.response.body = swaps[0];
|
);
|
||||||
} catch (error) {
|
|
||||||
console.error("Get swap error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create new employee swap (SuperAdmin only)
|
// Create new employee swap (SuperAdmin only)
|
||||||
router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
||||||
try {
|
try {
|
||||||
const currentUser = getCurrentUser(ctx);
|
const currentUser = getCurrentUser(ctx);
|
||||||
const body = await ctx.request.body.json() as CreateSwapRequest;
|
const body = await ctx.request.body.json() as CreateSwapRequest;
|
||||||
const {
|
const {
|
||||||
employeeId,
|
employeeId,
|
||||||
targetDepartmentId,
|
targetDepartmentId,
|
||||||
targetContractorId,
|
targetContractorId,
|
||||||
swapReason,
|
swapReason,
|
||||||
reasonDetails,
|
reasonDetails,
|
||||||
workCompletionPercentage,
|
workCompletionPercentage,
|
||||||
swapDate
|
swapDate,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!employeeId || !targetDepartmentId || !swapReason || !swapDate) {
|
if (!employeeId || !targetDepartmentId || !swapReason || !swapDate) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Employee ID, target department, swap reason, and swap date are required" };
|
ctx.response.body = {
|
||||||
|
error:
|
||||||
|
"Employee ID, target department, swap reason, and swap date are required",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate swap reason
|
// Validate swap reason
|
||||||
const validReasons = ["LeftWork", "Sick", "FinishedEarly", "Other"];
|
const validReasons = ["LeftWork", "Sick", "FinishedEarly", "Other"];
|
||||||
if (!validReasons.includes(swapReason)) {
|
if (!validReasons.includes(swapReason)) {
|
||||||
@@ -131,87 +156,108 @@ router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
|||||||
ctx.response.body = { error: "Invalid swap reason" };
|
ctx.response.body = { error: "Invalid swap reason" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get employee's current department and contractor
|
// Get employee's current department and contractor
|
||||||
const employees = await db.query<User[]>(
|
const employees = await db.query<User[]>(
|
||||||
"SELECT * FROM users WHERE id = ? AND role = 'Employee'",
|
"SELECT * FROM users WHERE id = ? AND role = 'Employee'",
|
||||||
[employeeId]
|
[employeeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (employees.length === 0) {
|
if (employees.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Employee not found" };
|
ctx.response.body = { error: "Employee not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const employee = employees[0];
|
const employee = employees[0];
|
||||||
|
|
||||||
if (!employee.department_id) {
|
if (!employee.department_id) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Employee has no current department" };
|
ctx.response.body = { error: "Employee has no current department" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there's already an active swap for this employee
|
// Check if there's already an active swap for this employee
|
||||||
const activeSwaps = await db.query<EmployeeSwap[]>(
|
const activeSwaps = await db.query<EmployeeSwap[]>(
|
||||||
"SELECT * FROM employee_swaps WHERE employee_id = ? AND status = 'Active'",
|
"SELECT * FROM employee_swaps WHERE employee_id = ? AND status = 'Active'",
|
||||||
[employeeId]
|
[employeeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activeSwaps.length > 0) {
|
if (activeSwaps.length > 0) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Employee already has an active swap. Complete or cancel it first." };
|
ctx.response.body = {
|
||||||
|
error:
|
||||||
|
"Employee already has an active swap. Complete or cancel it first.",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the swap record
|
// Use transaction to ensure both operations succeed or fail together
|
||||||
const result = await db.execute(
|
const newSwap = await db.transaction(async (connection) => {
|
||||||
`INSERT INTO employee_swaps
|
// Create the swap record
|
||||||
(employee_id, original_department_id, target_department_id, original_contractor_id, target_contractor_id,
|
const [insertResult] = await connection.execute(
|
||||||
swap_reason, reason_details, work_completion_percentage, swap_date, swapped_by, status)
|
`INSERT INTO employee_swaps
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'Active')`,
|
(employee_id, original_department_id, target_department_id, original_contractor_id, target_contractor_id,
|
||||||
[
|
swap_reason, reason_details, work_completion_percentage, swap_date, swapped_by, status)
|
||||||
employeeId,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'Active')`,
|
||||||
employee.department_id,
|
[
|
||||||
targetDepartmentId,
|
employeeId,
|
||||||
employee.contractor_id || null,
|
employee.department_id,
|
||||||
targetContractorId || null,
|
targetDepartmentId,
|
||||||
swapReason,
|
employee.contractor_id || null,
|
||||||
reasonDetails || null,
|
targetContractorId || null,
|
||||||
workCompletionPercentage || 0,
|
swapReason,
|
||||||
swapDate,
|
reasonDetails || null,
|
||||||
currentUser.id
|
workCompletionPercentage || 0,
|
||||||
]
|
swapDate,
|
||||||
);
|
currentUser.id,
|
||||||
|
],
|
||||||
// Update the employee's department and contractor
|
);
|
||||||
await db.execute(
|
|
||||||
"UPDATE users SET department_id = ?, contractor_id = ? WHERE id = ?",
|
const swapInsertId = (insertResult as { insertId: number }).insertId;
|
||||||
[targetDepartmentId, targetContractorId || null, employeeId]
|
|
||||||
);
|
if (!swapInsertId) {
|
||||||
|
throw new Error("Failed to create swap record");
|
||||||
// Fetch the created swap
|
}
|
||||||
const newSwap = await db.query<EmployeeSwap[]>(
|
|
||||||
`SELECT es.*,
|
// Update the employee's department and contractor
|
||||||
e.name as employee_name,
|
const [updateResult] = await connection.execute(
|
||||||
od.name as original_department_name,
|
"UPDATE users SET department_id = ?, contractor_id = ? WHERE id = ?",
|
||||||
td.name as target_department_name,
|
[targetDepartmentId, targetContractorId || null, employeeId],
|
||||||
oc.name as original_contractor_name,
|
);
|
||||||
tc.name as target_contractor_name,
|
|
||||||
sb.name as swapped_by_name
|
const affectedRows =
|
||||||
FROM employee_swaps es
|
(updateResult as { affectedRows: number }).affectedRows;
|
||||||
JOIN users e ON es.employee_id = e.id
|
|
||||||
JOIN departments od ON es.original_department_id = od.id
|
if (affectedRows === 0) {
|
||||||
JOIN departments td ON es.target_department_id = td.id
|
throw new Error("Failed to update employee department");
|
||||||
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
}
|
||||||
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
|
||||||
JOIN users sb ON es.swapped_by = sb.id
|
// Fetch the created swap
|
||||||
WHERE es.id = ?`,
|
const [swapRows] = await connection.query(
|
||||||
[result.insertId]
|
`SELECT es.*,
|
||||||
);
|
e.name as employee_name,
|
||||||
|
od.name as original_department_name,
|
||||||
|
td.name as target_department_name,
|
||||||
|
oc.name as original_contractor_name,
|
||||||
|
tc.name as target_contractor_name,
|
||||||
|
sb.name as swapped_by_name
|
||||||
|
FROM employee_swaps es
|
||||||
|
JOIN users e ON es.employee_id = e.id
|
||||||
|
JOIN departments od ON es.original_department_id = od.id
|
||||||
|
JOIN departments td ON es.target_department_id = td.id
|
||||||
|
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
||||||
|
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
||||||
|
JOIN users sb ON es.swapped_by = sb.id
|
||||||
|
WHERE es.id = ?`,
|
||||||
|
[swapInsertId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (swapRows as EmployeeSwap[])[0];
|
||||||
|
});
|
||||||
|
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = newSwap[0];
|
ctx.response.body = newSwap;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create swap error:", error);
|
console.error("Create swap error:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
@@ -220,121 +266,149 @@ router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Complete a swap (return employee to original department)
|
// Complete a swap (return employee to original department)
|
||||||
router.put("/:id/complete", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
router.put(
|
||||||
try {
|
"/:id/complete",
|
||||||
const swapId = ctx.params.id;
|
authenticateToken,
|
||||||
|
authorize("SuperAdmin"),
|
||||||
// Get the swap record
|
async (ctx) => {
|
||||||
const swaps = await db.query<EmployeeSwap[]>(
|
try {
|
||||||
"SELECT * FROM employee_swaps WHERE id = ? AND status = 'Active'",
|
const swapId = ctx.params.id;
|
||||||
[swapId]
|
|
||||||
);
|
// Get the swap record
|
||||||
|
const swaps = await db.query<EmployeeSwap[]>(
|
||||||
if (swaps.length === 0) {
|
"SELECT * FROM employee_swaps WHERE id = ? AND status = 'Active'",
|
||||||
ctx.response.status = 404;
|
[swapId],
|
||||||
ctx.response.body = { error: "Active swap not found" };
|
);
|
||||||
return;
|
|
||||||
|
if (swaps.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { error: "Active swap not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const swap = swaps[0];
|
||||||
|
|
||||||
|
// Use transaction to ensure both operations succeed or fail together
|
||||||
|
const updatedSwap = await db.transaction(async (connection) => {
|
||||||
|
// Return employee to original department and contractor
|
||||||
|
await connection.execute(
|
||||||
|
"UPDATE users SET department_id = ?, contractor_id = ? WHERE id = ?",
|
||||||
|
[
|
||||||
|
swap.original_department_id,
|
||||||
|
swap.original_contractor_id,
|
||||||
|
swap.employee_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark swap as completed
|
||||||
|
await connection.execute(
|
||||||
|
"UPDATE employee_swaps SET status = 'Completed', completed_at = NOW() WHERE id = ?",
|
||||||
|
[swapId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch updated swap
|
||||||
|
const [swapRows] = await connection.query(
|
||||||
|
`SELECT es.*,
|
||||||
|
e.name as employee_name,
|
||||||
|
od.name as original_department_name,
|
||||||
|
td.name as target_department_name,
|
||||||
|
oc.name as original_contractor_name,
|
||||||
|
tc.name as target_contractor_name,
|
||||||
|
sb.name as swapped_by_name
|
||||||
|
FROM employee_swaps es
|
||||||
|
JOIN users e ON es.employee_id = e.id
|
||||||
|
JOIN departments od ON es.original_department_id = od.id
|
||||||
|
JOIN departments td ON es.target_department_id = td.id
|
||||||
|
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
||||||
|
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
||||||
|
JOIN users sb ON es.swapped_by = sb.id
|
||||||
|
WHERE es.id = ?`,
|
||||||
|
[swapId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (swapRows as EmployeeSwap[])[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.body = updatedSwap;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Complete swap error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const swap = swaps[0];
|
);
|
||||||
|
|
||||||
// Return employee to original department and contractor
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE users SET department_id = ?, contractor_id = ? WHERE id = ?",
|
|
||||||
[swap.original_department_id, swap.original_contractor_id, swap.employee_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mark swap as completed
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE employee_swaps SET status = 'Completed', completed_at = NOW() WHERE id = ?",
|
|
||||||
[swapId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch updated swap
|
|
||||||
const updatedSwap = await db.query<EmployeeSwap[]>(
|
|
||||||
`SELECT es.*,
|
|
||||||
e.name as employee_name,
|
|
||||||
od.name as original_department_name,
|
|
||||||
td.name as target_department_name,
|
|
||||||
oc.name as original_contractor_name,
|
|
||||||
tc.name as target_contractor_name,
|
|
||||||
sb.name as swapped_by_name
|
|
||||||
FROM employee_swaps es
|
|
||||||
JOIN users e ON es.employee_id = e.id
|
|
||||||
JOIN departments od ON es.original_department_id = od.id
|
|
||||||
JOIN departments td ON es.target_department_id = td.id
|
|
||||||
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
|
||||||
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
|
||||||
JOIN users sb ON es.swapped_by = sb.id
|
|
||||||
WHERE es.id = ?`,
|
|
||||||
[swapId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.body = updatedSwap[0];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Complete swap error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cancel a swap (return employee to original department)
|
// Cancel a swap (return employee to original department)
|
||||||
router.put("/:id/cancel", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
router.put(
|
||||||
try {
|
"/:id/cancel",
|
||||||
const swapId = ctx.params.id;
|
authenticateToken,
|
||||||
|
authorize("SuperAdmin"),
|
||||||
// Get the swap record
|
async (ctx) => {
|
||||||
const swaps = await db.query<EmployeeSwap[]>(
|
try {
|
||||||
"SELECT * FROM employee_swaps WHERE id = ? AND status = 'Active'",
|
const swapId = ctx.params.id;
|
||||||
[swapId]
|
|
||||||
);
|
// Get the swap record
|
||||||
|
const swaps = await db.query<EmployeeSwap[]>(
|
||||||
if (swaps.length === 0) {
|
"SELECT * FROM employee_swaps WHERE id = ? AND status = 'Active'",
|
||||||
ctx.response.status = 404;
|
[swapId],
|
||||||
ctx.response.body = { error: "Active swap not found" };
|
);
|
||||||
return;
|
|
||||||
|
if (swaps.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { error: "Active swap not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const swap = swaps[0];
|
||||||
|
|
||||||
|
// Use transaction to ensure both operations succeed or fail together
|
||||||
|
const updatedSwap = await db.transaction(async (connection) => {
|
||||||
|
// Return employee to original department and contractor
|
||||||
|
await connection.execute(
|
||||||
|
"UPDATE users SET department_id = ?, contractor_id = ? WHERE id = ?",
|
||||||
|
[
|
||||||
|
swap.original_department_id,
|
||||||
|
swap.original_contractor_id,
|
||||||
|
swap.employee_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark swap as cancelled
|
||||||
|
await connection.execute(
|
||||||
|
"UPDATE employee_swaps SET status = 'Cancelled', completed_at = NOW() WHERE id = ?",
|
||||||
|
[swapId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch updated swap
|
||||||
|
const [swapRows] = await connection.query(
|
||||||
|
`SELECT es.*,
|
||||||
|
e.name as employee_name,
|
||||||
|
od.name as original_department_name,
|
||||||
|
td.name as target_department_name,
|
||||||
|
oc.name as original_contractor_name,
|
||||||
|
tc.name as target_contractor_name,
|
||||||
|
sb.name as swapped_by_name
|
||||||
|
FROM employee_swaps es
|
||||||
|
JOIN users e ON es.employee_id = e.id
|
||||||
|
JOIN departments od ON es.original_department_id = od.id
|
||||||
|
JOIN departments td ON es.target_department_id = td.id
|
||||||
|
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
||||||
|
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
||||||
|
JOIN users sb ON es.swapped_by = sb.id
|
||||||
|
WHERE es.id = ?`,
|
||||||
|
[swapId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (swapRows as EmployeeSwap[])[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.body = updatedSwap;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Cancel swap error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const swap = swaps[0];
|
);
|
||||||
|
|
||||||
// Return employee to original department and contractor
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE users SET department_id = ?, contractor_id = ? WHERE id = ?",
|
|
||||||
[swap.original_department_id, swap.original_contractor_id, swap.employee_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mark swap as cancelled
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE employee_swaps SET status = 'Cancelled', completed_at = NOW() WHERE id = ?",
|
|
||||||
[swapId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch updated swap
|
|
||||||
const updatedSwap = await db.query<EmployeeSwap[]>(
|
|
||||||
`SELECT es.*,
|
|
||||||
e.name as employee_name,
|
|
||||||
od.name as original_department_name,
|
|
||||||
td.name as target_department_name,
|
|
||||||
oc.name as original_contractor_name,
|
|
||||||
tc.name as target_contractor_name,
|
|
||||||
sb.name as swapped_by_name
|
|
||||||
FROM employee_swaps es
|
|
||||||
JOIN users e ON es.employee_id = e.id
|
|
||||||
JOIN departments od ON es.original_department_id = od.id
|
|
||||||
JOIN departments td ON es.target_department_id = td.id
|
|
||||||
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
|
||||||
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
|
||||||
JOIN users sb ON es.swapped_by = sb.id
|
|
||||||
WHERE es.id = ?`,
|
|
||||||
[swapId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.body = updatedSwap[0];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Cancel swap error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
import type { WorkAllocation } from "../types/index.ts";
|
authenticateToken,
|
||||||
|
authorize,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
|
import type { JWTPayload, WorkAllocation } from "../types/index.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
// Get completed work allocations for reporting (with optional filters)
|
// Get completed work allocations for reporting (with optional filters)
|
||||||
router.get("/completed-allocations", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.get(
|
||||||
try {
|
"/completed-allocations",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const params = ctx.request.url.searchParams;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const startDate = params.get("startDate");
|
async (ctx) => {
|
||||||
const endDate = params.get("endDate");
|
try {
|
||||||
const departmentId = params.get("departmentId");
|
const currentUser: JWTPayload = getCurrentUser(ctx);
|
||||||
const contractorId = params.get("contractorId");
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
||||||
const employeeId = params.get("employeeId");
|
const startDate: string | null = params.get("startDate");
|
||||||
|
const endDate: string | null = params.get("endDate");
|
||||||
let query = `
|
const departmentId: string | null = params.get("departmentId");
|
||||||
|
const contractorId: string | null = params.get("contractorId");
|
||||||
|
const employeeId: string | null = params.get("employeeId");
|
||||||
|
|
||||||
|
let query = `
|
||||||
SELECT wa.*,
|
SELECT wa.*,
|
||||||
e.name as employee_name, e.username as employee_username,
|
e.name as employee_name, e.username as employee_username,
|
||||||
e.phone_number as employee_phone,
|
e.phone_number as employee_phone,
|
||||||
@@ -33,95 +41,110 @@ router.get("/completed-allocations", authenticateToken, authorize("Supervisor",
|
|||||||
LEFT JOIN departments d ON e.department_id = d.id
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
WHERE wa.status = 'Completed'
|
WHERE wa.status = 'Completed'
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|
||||||
// Role-based filtering - Supervisors can only see their department
|
// Role-based filtering - Supervisors can only see their department
|
||||||
if (currentUser.role === "Supervisor") {
|
if (currentUser.role === "Supervisor") {
|
||||||
query += " AND e.department_id = ?";
|
query += " AND e.department_id = ?";
|
||||||
queryParams.push(currentUser.departmentId);
|
queryParams.push(currentUser.departmentId);
|
||||||
}
|
|
||||||
|
|
||||||
// Date range filter
|
|
||||||
if (startDate) {
|
|
||||||
query += " AND wa.completion_date >= ?";
|
|
||||||
queryParams.push(startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
query += " AND wa.completion_date <= ?";
|
|
||||||
queryParams.push(endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Department filter (for SuperAdmin)
|
|
||||||
if (departmentId && currentUser.role === "SuperAdmin") {
|
|
||||||
query += " AND e.department_id = ?";
|
|
||||||
queryParams.push(departmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contractor filter
|
|
||||||
if (contractorId) {
|
|
||||||
query += " AND wa.contractor_id = ?";
|
|
||||||
queryParams.push(contractorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Employee filter
|
|
||||||
if (employeeId) {
|
|
||||||
query += " AND wa.employee_id = ?";
|
|
||||||
queryParams.push(employeeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY wa.completion_date DESC, wa.created_at DESC";
|
|
||||||
|
|
||||||
const allocations = await db.query<WorkAllocation[]>(query, queryParams);
|
|
||||||
|
|
||||||
// Calculate summary stats
|
|
||||||
const totalAllocations = allocations.length;
|
|
||||||
const totalAmount = allocations.reduce((sum, a) => sum + (parseFloat(String(a.total_amount)) || parseFloat(String(a.rate)) || 0), 0);
|
|
||||||
const totalUnits = allocations.reduce((sum, a) => sum + (parseFloat(String(a.units)) || 0), 0);
|
|
||||||
|
|
||||||
ctx.response.body = {
|
|
||||||
allocations,
|
|
||||||
summary: {
|
|
||||||
totalAllocations,
|
|
||||||
totalAmount: totalAmount.toFixed(2),
|
|
||||||
totalUnits: totalUnits.toFixed(2),
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
} catch (error) {
|
// Date range filter
|
||||||
console.error("Get completed allocations report error:", error);
|
if (startDate) {
|
||||||
ctx.response.status = 500;
|
query += " AND wa.completion_date >= ?";
|
||||||
ctx.response.body = { error: "Internal server error" };
|
queryParams.push(startDate);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
if (endDate) {
|
||||||
|
query += " AND wa.completion_date <= ?";
|
||||||
|
queryParams.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Department filter (for SuperAdmin)
|
||||||
|
if (departmentId && currentUser.role === "SuperAdmin") {
|
||||||
|
query += " AND e.department_id = ?";
|
||||||
|
queryParams.push(departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contractor filter
|
||||||
|
if (contractorId) {
|
||||||
|
query += " AND wa.contractor_id = ?";
|
||||||
|
queryParams.push(contractorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Employee filter
|
||||||
|
if (employeeId) {
|
||||||
|
query += " AND wa.employee_id = ?";
|
||||||
|
queryParams.push(employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY wa.completion_date DESC, wa.created_at DESC";
|
||||||
|
|
||||||
|
const allocations = await db.query<WorkAllocation[]>(query, queryParams);
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const totalAllocations = allocations.length;
|
||||||
|
const totalAmount = allocations.reduce(
|
||||||
|
(sum, a) =>
|
||||||
|
sum +
|
||||||
|
(parseFloat(String(a.total_amount)) || parseFloat(String(a.rate)) ||
|
||||||
|
0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalUnits = allocations.reduce(
|
||||||
|
(sum, a) => sum + (parseFloat(String(a.units)) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
allocations,
|
||||||
|
summary: {
|
||||||
|
totalAllocations,
|
||||||
|
totalAmount: totalAmount.toFixed(2),
|
||||||
|
totalUnits: totalUnits.toFixed(2),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get completed allocations report error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get summary statistics for completed work
|
// Get summary statistics for completed work
|
||||||
router.get("/summary", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.get(
|
||||||
try {
|
"/summary",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const params = ctx.request.url.searchParams;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const startDate = params.get("startDate");
|
async (ctx) => {
|
||||||
const endDate = params.get("endDate");
|
try {
|
||||||
|
const currentUser: JWTPayload = getCurrentUser(ctx);
|
||||||
let departmentFilter = "";
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
||||||
const queryParams: unknown[] = [];
|
const startDate: string | null = params.get("startDate");
|
||||||
|
const endDate: string | null = params.get("endDate");
|
||||||
if (currentUser.role === "Supervisor") {
|
|
||||||
departmentFilter = " AND e.department_id = ?";
|
let departmentFilter = "";
|
||||||
queryParams.push(currentUser.departmentId);
|
const queryParams: unknown[] = [];
|
||||||
}
|
|
||||||
|
if (currentUser.role === "Supervisor") {
|
||||||
let dateFilter = "";
|
departmentFilter = " AND e.department_id = ?";
|
||||||
if (startDate) {
|
queryParams.push(currentUser.departmentId);
|
||||||
dateFilter += " AND wa.completion_date >= ?";
|
}
|
||||||
queryParams.push(startDate);
|
|
||||||
}
|
let dateFilter = "";
|
||||||
if (endDate) {
|
if (startDate) {
|
||||||
dateFilter += " AND wa.completion_date <= ?";
|
dateFilter += " AND wa.completion_date >= ?";
|
||||||
queryParams.push(endDate);
|
queryParams.push(startDate);
|
||||||
}
|
}
|
||||||
|
if (endDate) {
|
||||||
// Get summary by contractor
|
dateFilter += " AND wa.completion_date <= ?";
|
||||||
const byContractor = await db.query<any[]>(`
|
queryParams.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get summary by contractor
|
||||||
|
const byContractor = await db.query<any[]>(
|
||||||
|
`
|
||||||
SELECT
|
SELECT
|
||||||
c.id as contractor_id,
|
c.id as contractor_id,
|
||||||
c.name as contractor_name,
|
c.name as contractor_name,
|
||||||
@@ -134,10 +157,13 @@ router.get("/summary", authenticateToken, authorize("Supervisor", "SuperAdmin"),
|
|||||||
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
|
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.id, c.name
|
||||||
ORDER BY total_amount DESC
|
ORDER BY total_amount DESC
|
||||||
`, queryParams);
|
`,
|
||||||
|
queryParams,
|
||||||
// Get summary by sub-department
|
);
|
||||||
const bySubDepartment = await db.query<any[]>(`
|
|
||||||
|
// Get summary by sub-department
|
||||||
|
const bySubDepartment = await db.query<any[]>(
|
||||||
|
`
|
||||||
SELECT
|
SELECT
|
||||||
sd.id as sub_department_id,
|
sd.id as sub_department_id,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
@@ -152,10 +178,13 @@ router.get("/summary", authenticateToken, authorize("Supervisor", "SuperAdmin"),
|
|||||||
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
|
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
|
||||||
GROUP BY sd.id, sd.name, d.name
|
GROUP BY sd.id, sd.name, d.name
|
||||||
ORDER BY total_amount DESC
|
ORDER BY total_amount DESC
|
||||||
`, queryParams);
|
`,
|
||||||
|
queryParams,
|
||||||
// Get summary by activity type
|
);
|
||||||
const byActivity = await db.query<any[]>(`
|
|
||||||
|
// Get summary by activity type
|
||||||
|
const byActivity = await db.query<any[]>(
|
||||||
|
`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(wa.activity, 'Standard') as activity,
|
COALESCE(wa.activity, 'Standard') as activity,
|
||||||
COUNT(*) as total_allocations,
|
COUNT(*) as total_allocations,
|
||||||
@@ -166,18 +195,21 @@ router.get("/summary", authenticateToken, authorize("Supervisor", "SuperAdmin"),
|
|||||||
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
|
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
|
||||||
GROUP BY wa.activity
|
GROUP BY wa.activity
|
||||||
ORDER BY total_amount DESC
|
ORDER BY total_amount DESC
|
||||||
`, queryParams);
|
`,
|
||||||
|
queryParams,
|
||||||
ctx.response.body = {
|
);
|
||||||
byContractor,
|
|
||||||
bySubDepartment,
|
ctx.response.body = {
|
||||||
byActivity,
|
byContractor,
|
||||||
};
|
bySubDepartment,
|
||||||
} catch (error) {
|
byActivity,
|
||||||
console.error("Get report summary error:", error);
|
};
|
||||||
ctx.response.status = 500;
|
} catch (error) {
|
||||||
ctx.response.body = { error: "Internal server error" };
|
console.error("Get report summary error:", error);
|
||||||
}
|
ctx.response.status = 500;
|
||||||
});
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
|
authenticateToken,
|
||||||
|
authorize,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
import { sanitizeInput } from "../middleware/security.ts";
|
import { sanitizeInput } from "../middleware/security.ts";
|
||||||
|
import type { Context } from "@oak/oak";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
@@ -21,14 +26,16 @@ interface StandardRate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all standard rates (default rates for comparison)
|
// Get all standard rates (default rates for comparison)
|
||||||
router.get("/", authenticateToken, async (ctx) => {
|
router.get("/", authenticateToken, async (ctx: Context) => {
|
||||||
try {
|
try {
|
||||||
const currentUser = getCurrentUser(ctx);
|
const currentUser = getCurrentUser(ctx);
|
||||||
const params = ctx.request.url.searchParams;
|
const params = ctx.request.url.searchParams;
|
||||||
const departmentId = params.get("departmentId");
|
const departmentId: string | number | null = params.get("departmentId");
|
||||||
const subDepartmentId = params.get("subDepartmentId");
|
const subDepartmentId: string | number | null = params.get(
|
||||||
const activity = params.get("activity");
|
"subDepartmentId",
|
||||||
|
);
|
||||||
|
const activity: string | null = params.get("activity");
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT sr.*,
|
SELECT sr.*,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
@@ -44,30 +51,30 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|
||||||
// Supervisors can only see rates for their department
|
// Supervisors can only see rates for their department
|
||||||
if (currentUser.role === "Supervisor") {
|
if (currentUser.role === "Supervisor") {
|
||||||
query += " AND d.id = ?";
|
query += " AND d.id = ?";
|
||||||
queryParams.push(currentUser.departmentId);
|
queryParams.push(currentUser.departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (departmentId) {
|
if (departmentId) {
|
||||||
query += " AND d.id = ?";
|
query += " AND d.id = ?";
|
||||||
queryParams.push(departmentId);
|
queryParams.push(departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subDepartmentId) {
|
if (subDepartmentId) {
|
||||||
query += " AND sr.sub_department_id = ?";
|
query += " AND sr.sub_department_id = ?";
|
||||||
queryParams.push(subDepartmentId);
|
queryParams.push(subDepartmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activity) {
|
if (activity) {
|
||||||
query += " AND sr.activity = ?";
|
query += " AND sr.activity = ?";
|
||||||
queryParams.push(activity);
|
queryParams.push(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
query += " ORDER BY sr.effective_date DESC, sr.created_at DESC";
|
query += " ORDER BY sr.effective_date DESC, sr.created_at DESC";
|
||||||
|
|
||||||
const rates = await db.query<StandardRate[]>(query, queryParams);
|
const rates = await db.query<StandardRate[]>(query, queryParams);
|
||||||
ctx.response.body = rates;
|
ctx.response.body = rates;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -78,15 +85,19 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get all rates (contractor + standard) for SuperAdmin - all departments, sorted by date
|
// Get all rates (contractor + standard) for SuperAdmin - all departments, sorted by date
|
||||||
router.get("/all-rates", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
router.get(
|
||||||
try {
|
"/all-rates",
|
||||||
const params = ctx.request.url.searchParams;
|
authenticateToken,
|
||||||
const departmentId = params.get("departmentId");
|
authorize("SuperAdmin"),
|
||||||
const startDate = params.get("startDate");
|
async (ctx: Context) => {
|
||||||
const endDate = params.get("endDate");
|
try {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
// Get contractor rates
|
const departmentId: string | number | null = params.get("departmentId");
|
||||||
let contractorQuery = `
|
const startDate: string | null = params.get("startDate");
|
||||||
|
const endDate: string | null = params.get("endDate");
|
||||||
|
|
||||||
|
// Get contractor rates
|
||||||
|
let contractorQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
cr.id,
|
cr.id,
|
||||||
'contractor' as rate_type,
|
'contractor' as rate_type,
|
||||||
@@ -108,25 +119,25 @@ router.get("/all-rates", authenticateToken, authorize("SuperAdmin"), async (ctx)
|
|||||||
LEFT JOIN departments d ON sd.department_id = d.id
|
LEFT JOIN departments d ON sd.department_id = d.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const contractorParams: unknown[] = [];
|
const contractorParams: unknown[] = [];
|
||||||
|
|
||||||
if (departmentId) {
|
if (departmentId) {
|
||||||
contractorQuery += " AND d.id = ?";
|
contractorQuery += " AND d.id = ?";
|
||||||
contractorParams.push(departmentId);
|
contractorParams.push(departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startDate) {
|
if (startDate) {
|
||||||
contractorQuery += " AND cr.effective_date >= ?";
|
contractorQuery += " AND cr.effective_date >= ?";
|
||||||
contractorParams.push(startDate);
|
contractorParams.push(startDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endDate) {
|
if (endDate) {
|
||||||
contractorQuery += " AND cr.effective_date <= ?";
|
contractorQuery += " AND cr.effective_date <= ?";
|
||||||
contractorParams.push(endDate);
|
contractorParams.push(endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get standard rates
|
// Get standard rates
|
||||||
let standardQuery = `
|
let standardQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
sr.id,
|
sr.id,
|
||||||
'standard' as rate_type,
|
'standard' as rate_type,
|
||||||
@@ -148,66 +159,77 @@ router.get("/all-rates", authenticateToken, authorize("SuperAdmin"), async (ctx)
|
|||||||
LEFT JOIN users u ON sr.created_by = u.id
|
LEFT JOIN users u ON sr.created_by = u.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const standardParams: unknown[] = [];
|
const standardParams: unknown[] = [];
|
||||||
|
|
||||||
if (departmentId) {
|
if (departmentId) {
|
||||||
standardQuery += " AND d.id = ?";
|
standardQuery += " AND d.id = ?";
|
||||||
standardParams.push(departmentId);
|
standardParams.push(departmentId);
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
standardQuery += " AND sr.effective_date >= ?";
|
|
||||||
standardParams.push(startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
standardQuery += " AND sr.effective_date <= ?";
|
|
||||||
standardParams.push(endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contractorRates = await db.query<any[]>(contractorQuery, contractorParams);
|
|
||||||
const standardRates = await db.query<any[]>(standardQuery, standardParams);
|
|
||||||
|
|
||||||
// Combine and sort by date
|
|
||||||
const allRates = [...contractorRates, ...standardRates].sort((a, b) => {
|
|
||||||
const dateA = new Date(a.effective_date).getTime();
|
|
||||||
const dateB = new Date(b.effective_date).getTime();
|
|
||||||
return dateB - dateA; // Descending order
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.response.body = {
|
|
||||||
allRates,
|
|
||||||
summary: {
|
|
||||||
totalContractorRates: contractorRates.length,
|
|
||||||
totalStandardRates: standardRates.length,
|
|
||||||
totalRates: allRates.length,
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
} catch (error) {
|
if (startDate) {
|
||||||
console.error("Get all rates error:", error);
|
standardQuery += " AND sr.effective_date >= ?";
|
||||||
ctx.response.status = 500;
|
standardParams.push(startDate);
|
||||||
ctx.response.body = { error: "Internal server error" };
|
}
|
||||||
}
|
|
||||||
});
|
if (endDate) {
|
||||||
|
standardQuery += " AND sr.effective_date <= ?";
|
||||||
|
standardParams.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractorRates = await db.query<any[]>(
|
||||||
|
contractorQuery,
|
||||||
|
contractorParams,
|
||||||
|
);
|
||||||
|
const standardRates = await db.query<any[]>(
|
||||||
|
standardQuery,
|
||||||
|
standardParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine and sort by date
|
||||||
|
const allRates = [...contractorRates, ...standardRates].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.effective_date).getTime();
|
||||||
|
const dateB = new Date(b.effective_date).getTime();
|
||||||
|
return dateB - dateA; // Descending order
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
allRates,
|
||||||
|
summary: {
|
||||||
|
totalContractorRates: contractorRates.length,
|
||||||
|
totalStandardRates: standardRates.length,
|
||||||
|
totalRates: allRates.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get all rates error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Compare contractor rates with standard rates
|
// Compare contractor rates with standard rates
|
||||||
router.get("/compare", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.get(
|
||||||
try {
|
"/compare",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const params = ctx.request.url.searchParams;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const contractorId = params.get("contractorId");
|
async (ctx) => {
|
||||||
const subDepartmentId = params.get("subDepartmentId");
|
try {
|
||||||
|
const currentUser = getCurrentUser(ctx);
|
||||||
let departmentFilter = "";
|
const params = ctx.request.url.searchParams;
|
||||||
const queryParams: unknown[] = [];
|
const contractorId = params.get("contractorId");
|
||||||
|
const subDepartmentId = params.get("subDepartmentId");
|
||||||
if (currentUser.role === "Supervisor") {
|
|
||||||
departmentFilter = " AND d.id = ?";
|
let departmentFilter = "";
|
||||||
queryParams.push(currentUser.departmentId);
|
const queryParams: unknown[] = [];
|
||||||
}
|
|
||||||
|
if (currentUser.role === "Supervisor") {
|
||||||
// Get standard rates
|
departmentFilter = " AND d.id = ?";
|
||||||
let standardQuery = `
|
queryParams.push(currentUser.departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get standard rates
|
||||||
|
let standardQuery = `
|
||||||
SELECT sr.*,
|
SELECT sr.*,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
d.name as department_name,
|
d.name as department_name,
|
||||||
@@ -219,18 +241,21 @@ router.get("/compare", authenticateToken, authorize("Supervisor", "SuperAdmin"),
|
|||||||
LEFT JOIN activities a ON a.sub_department_id = sr.sub_department_id AND a.name = sr.activity
|
LEFT JOIN activities a ON a.sub_department_id = sr.sub_department_id AND a.name = sr.activity
|
||||||
WHERE 1=1 ${departmentFilter}
|
WHERE 1=1 ${departmentFilter}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (subDepartmentId) {
|
if (subDepartmentId) {
|
||||||
standardQuery += " AND sr.sub_department_id = ?";
|
standardQuery += " AND sr.sub_department_id = ?";
|
||||||
queryParams.push(subDepartmentId);
|
queryParams.push(subDepartmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
standardQuery += " ORDER BY sr.effective_date DESC";
|
standardQuery += " ORDER BY sr.effective_date DESC";
|
||||||
|
|
||||||
const standardRates = await db.query<StandardRate[]>(standardQuery, queryParams);
|
const standardRates = await db.query<StandardRate[]>(
|
||||||
|
standardQuery,
|
||||||
// Get contractor rates for comparison
|
queryParams,
|
||||||
let contractorQuery = `
|
);
|
||||||
|
|
||||||
|
// Get contractor rates for comparison
|
||||||
|
let contractorQuery = `
|
||||||
SELECT cr.*,
|
SELECT cr.*,
|
||||||
u.name as contractor_name,
|
u.name as contractor_name,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
@@ -244,103 +269,123 @@ router.get("/compare", authenticateToken, authorize("Supervisor", "SuperAdmin"),
|
|||||||
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const contractorParams: unknown[] = [];
|
const contractorParams: unknown[] = [];
|
||||||
|
|
||||||
if (currentUser.role === "Supervisor") {
|
if (currentUser.role === "Supervisor") {
|
||||||
contractorQuery += " AND d.id = ?";
|
contractorQuery += " AND d.id = ?";
|
||||||
contractorParams.push(currentUser.departmentId);
|
contractorParams.push(currentUser.departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contractorId) {
|
if (contractorId) {
|
||||||
contractorQuery += " AND cr.contractor_id = ?";
|
contractorQuery += " AND cr.contractor_id = ?";
|
||||||
contractorParams.push(contractorId);
|
contractorParams.push(contractorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subDepartmentId) {
|
if (subDepartmentId) {
|
||||||
contractorQuery += " AND cr.sub_department_id = ?";
|
contractorQuery += " AND cr.sub_department_id = ?";
|
||||||
contractorParams.push(subDepartmentId);
|
contractorParams.push(subDepartmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
contractorQuery += " ORDER BY cr.effective_date DESC";
|
contractorQuery += " ORDER BY cr.effective_date DESC";
|
||||||
|
|
||||||
const contractorRates = await db.query<any[]>(contractorQuery, contractorParams);
|
const contractorRates = await db.query<any[]>(
|
||||||
|
contractorQuery,
|
||||||
// Build comparison data
|
contractorParams,
|
||||||
const comparisons = contractorRates.map(cr => {
|
|
||||||
// Find matching standard rate
|
|
||||||
const matchingStandard = standardRates.find(sr =>
|
|
||||||
sr.sub_department_id === cr.sub_department_id &&
|
|
||||||
sr.activity === cr.activity
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const standardRate = matchingStandard?.rate || 0;
|
// Build comparison data
|
||||||
const contractorRate = cr.rate || 0;
|
const comparisons = contractorRates.map((cr) => {
|
||||||
const difference = contractorRate - standardRate;
|
// Find matching standard rate
|
||||||
const percentageDiff = standardRate > 0 ? ((difference / standardRate) * 100).toFixed(2) : null;
|
const matchingStandard = standardRates.find((sr) =>
|
||||||
|
sr.sub_department_id === cr.sub_department_id &&
|
||||||
return {
|
sr.activity === cr.activity
|
||||||
...cr,
|
);
|
||||||
standard_rate: standardRate,
|
|
||||||
difference,
|
const standardRate = matchingStandard?.rate || 0;
|
||||||
percentage_difference: percentageDiff,
|
const contractorRate = cr.rate || 0;
|
||||||
is_above_standard: difference > 0,
|
const difference = contractorRate - standardRate;
|
||||||
is_below_standard: difference < 0,
|
const percentageDiff = standardRate > 0
|
||||||
|
? ((difference / standardRate) * 100).toFixed(2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cr,
|
||||||
|
standard_rate: standardRate,
|
||||||
|
difference,
|
||||||
|
percentage_difference: percentageDiff,
|
||||||
|
is_above_standard: difference > 0,
|
||||||
|
is_below_standard: difference < 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
standardRates,
|
||||||
|
contractorRates,
|
||||||
|
comparisons,
|
||||||
};
|
};
|
||||||
});
|
} catch (error) {
|
||||||
|
console.error("Compare rates error:", error);
|
||||||
ctx.response.body = {
|
ctx.response.status = 500;
|
||||||
standardRates,
|
ctx.response.body = { error: "Internal server error" };
|
||||||
contractorRates,
|
}
|
||||||
comparisons,
|
},
|
||||||
};
|
);
|
||||||
} catch (error) {
|
|
||||||
console.error("Compare rates error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create standard rate (Supervisor or SuperAdmin)
|
// Create standard rate (Supervisor or SuperAdmin)
|
||||||
router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.post(
|
||||||
try {
|
"/",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const body = await ctx.request.body.json() as {
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
subDepartmentId?: number;
|
async (ctx) => {
|
||||||
activity?: string;
|
try {
|
||||||
rate: number;
|
const currentUser = getCurrentUser(ctx);
|
||||||
effectiveDate: string;
|
const body = await ctx.request.body.json() as {
|
||||||
};
|
subDepartmentId?: number;
|
||||||
const { subDepartmentId, activity, rate, effectiveDate } = body;
|
activity?: string;
|
||||||
|
rate: number;
|
||||||
if (!rate || !effectiveDate) {
|
effectiveDate: string;
|
||||||
ctx.response.status = 400;
|
};
|
||||||
ctx.response.body = { error: "Missing required fields (rate, effectiveDate)" };
|
const { subDepartmentId, activity, rate, effectiveDate } = body;
|
||||||
return;
|
|
||||||
}
|
if (!rate || !effectiveDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
// Verify sub-department belongs to supervisor's department if supervisor
|
ctx.response.body = {
|
||||||
if (subDepartmentId && currentUser.role === "Supervisor") {
|
error: "Missing required fields (rate, effectiveDate)",
|
||||||
const subDepts = await db.query<any[]>(
|
};
|
||||||
"SELECT sd.* FROM sub_departments sd JOIN departments d ON sd.department_id = d.id WHERE sd.id = ? AND d.id = ?",
|
|
||||||
[subDepartmentId, currentUser.departmentId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (subDepts.length === 0) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Sub-department not in your department" };
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Verify sub-department belongs to supervisor's department if supervisor
|
||||||
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
|
if (subDepartmentId && currentUser.role === "Supervisor") {
|
||||||
|
const subDepts = await db.query<any[]>(
|
||||||
const result = await db.execute(
|
"SELECT sd.* FROM sub_departments sd JOIN departments d ON sd.department_id = d.id WHERE sd.id = ? AND d.id = ?",
|
||||||
"INSERT INTO standard_rates (sub_department_id, activity, rate, effective_date, created_by) VALUES (?, ?, ?, ?, ?)",
|
[subDepartmentId, currentUser.departmentId],
|
||||||
[subDepartmentId || null, sanitizedActivity, rate, effectiveDate, currentUser.id]
|
);
|
||||||
);
|
|
||||||
|
if (subDepts.length === 0) {
|
||||||
const newRate = await db.query<StandardRate[]>(
|
ctx.response.status = 403;
|
||||||
`SELECT sr.*,
|
ctx.response.body = {
|
||||||
|
error: "Sub-department not in your department",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
|
||||||
|
|
||||||
|
const result = await db.execute(
|
||||||
|
"INSERT INTO standard_rates (sub_department_id, activity, rate, effective_date, created_by) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
subDepartmentId || null,
|
||||||
|
sanitizedActivity,
|
||||||
|
rate,
|
||||||
|
effectiveDate,
|
||||||
|
currentUser.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRate = await db.query<StandardRate[]>(
|
||||||
|
`SELECT sr.*,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
d.name as department_name,
|
d.name as department_name,
|
||||||
u.name as created_by_name,
|
u.name as created_by_name,
|
||||||
@@ -351,82 +396,96 @@ router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async
|
|||||||
LEFT JOIN users u ON sr.created_by = u.id
|
LEFT JOIN users u ON sr.created_by = u.id
|
||||||
LEFT JOIN activities a ON a.sub_department_id = sr.sub_department_id AND a.name = sr.activity
|
LEFT JOIN activities a ON a.sub_department_id = sr.sub_department_id AND a.name = sr.activity
|
||||||
WHERE sr.id = ?`,
|
WHERE sr.id = ?`,
|
||||||
[result.insertId]
|
[result.insertId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = newRate[0];
|
ctx.response.body = newRate[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create standard rate error:", error);
|
console.error("Create standard rate error:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
ctx.response.body = { error: "Internal server error" };
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update standard rate
|
// Update standard rate
|
||||||
router.put("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.put(
|
||||||
try {
|
"/:id",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const rateId = ctx.params.id;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const body = await ctx.request.body.json() as { rate?: number; activity?: string; effectiveDate?: string };
|
async (ctx) => {
|
||||||
const { rate, activity, effectiveDate } = body;
|
try {
|
||||||
|
const currentUser = getCurrentUser(ctx);
|
||||||
// Verify rate exists and user has access
|
const rateId = ctx.params.id;
|
||||||
let query = `
|
const body = await ctx.request.body.json() as {
|
||||||
|
rate?: number;
|
||||||
|
activity?: string;
|
||||||
|
effectiveDate?: string;
|
||||||
|
};
|
||||||
|
const { rate, activity, effectiveDate } = body;
|
||||||
|
|
||||||
|
// Verify rate exists and user has access
|
||||||
|
let query = `
|
||||||
SELECT sr.*, d.id as department_id
|
SELECT sr.*, d.id as department_id
|
||||||
FROM standard_rates sr
|
FROM standard_rates sr
|
||||||
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
|
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
|
||||||
LEFT JOIN departments d ON sd.department_id = d.id
|
LEFT JOIN departments d ON sd.department_id = d.id
|
||||||
WHERE sr.id = ?
|
WHERE sr.id = ?
|
||||||
`;
|
`;
|
||||||
const params: unknown[] = [rateId];
|
const params: unknown[] = [rateId];
|
||||||
|
|
||||||
const existing = await db.query<any[]>(query, params);
|
const existing = await db.query<any[]>(query, params);
|
||||||
|
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Standard rate not found" };
|
ctx.response.body = { error: "Standard rate not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supervisors can only update rates in their department
|
// Supervisors can only update rates in their department
|
||||||
if (currentUser.role === "Supervisor" && existing[0].department_id !== currentUser.departmentId) {
|
if (
|
||||||
ctx.response.status = 403;
|
currentUser.role === "Supervisor" &&
|
||||||
ctx.response.body = { error: "Access denied - rate not in your department" };
|
existing[0].department_id !== currentUser.departmentId
|
||||||
return;
|
) {
|
||||||
}
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
const updates: string[] = [];
|
error: "Access denied - rate not in your department",
|
||||||
const updateParams: unknown[] = [];
|
};
|
||||||
|
return;
|
||||||
if (rate !== undefined) {
|
}
|
||||||
updates.push("rate = ?");
|
|
||||||
updateParams.push(rate);
|
const updates: string[] = [];
|
||||||
}
|
const updateParams: unknown[] = [];
|
||||||
if (activity !== undefined) {
|
|
||||||
updates.push("activity = ?");
|
if (rate !== undefined) {
|
||||||
updateParams.push(sanitizeInput(activity));
|
updates.push("rate = ?");
|
||||||
}
|
updateParams.push(rate);
|
||||||
if (effectiveDate !== undefined) {
|
}
|
||||||
updates.push("effective_date = ?");
|
if (activity !== undefined) {
|
||||||
updateParams.push(effectiveDate);
|
updates.push("activity = ?");
|
||||||
}
|
updateParams.push(sanitizeInput(activity));
|
||||||
|
}
|
||||||
if (updates.length === 0) {
|
if (effectiveDate !== undefined) {
|
||||||
ctx.response.status = 400;
|
updates.push("effective_date = ?");
|
||||||
ctx.response.body = { error: "No fields to update" };
|
updateParams.push(effectiveDate);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
if (updates.length === 0) {
|
||||||
updateParams.push(rateId);
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "No fields to update" };
|
||||||
await db.execute(
|
return;
|
||||||
`UPDATE standard_rates SET ${updates.join(", ")} WHERE id = ?`,
|
}
|
||||||
updateParams
|
|
||||||
);
|
updateParams.push(rateId);
|
||||||
|
|
||||||
const updatedRate = await db.query<StandardRate[]>(
|
await db.execute(
|
||||||
`SELECT sr.*,
|
`UPDATE standard_rates SET ${updates.join(", ")} WHERE id = ?`,
|
||||||
|
updateParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedRate = await db.query<StandardRate[]>(
|
||||||
|
`SELECT sr.*,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
d.name as department_name,
|
d.name as department_name,
|
||||||
u.name as created_by_name,
|
u.name as created_by_name,
|
||||||
@@ -437,53 +496,64 @@ router.put("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), asy
|
|||||||
LEFT JOIN users u ON sr.created_by = u.id
|
LEFT JOIN users u ON sr.created_by = u.id
|
||||||
LEFT JOIN activities a ON a.sub_department_id = sr.sub_department_id AND a.name = sr.activity
|
LEFT JOIN activities a ON a.sub_department_id = sr.sub_department_id AND a.name = sr.activity
|
||||||
WHERE sr.id = ?`,
|
WHERE sr.id = ?`,
|
||||||
[rateId]
|
[rateId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.body = updatedRate[0];
|
ctx.response.body = updatedRate[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Update standard rate error:", error);
|
console.error("Update standard rate error:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
ctx.response.body = { error: "Internal server error" };
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Delete standard rate
|
// Delete standard rate
|
||||||
router.delete("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.delete(
|
||||||
try {
|
"/:id",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const rateId = ctx.params.id;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
|
async (ctx: Context) => {
|
||||||
// Verify rate exists and user has access
|
try {
|
||||||
const existing = await db.query<any[]>(
|
const currentUser = getCurrentUser(ctx);
|
||||||
`SELECT sr.*, d.id as department_id
|
const rateId = ctx.params.id;
|
||||||
|
|
||||||
|
// Verify rate exists and user has access
|
||||||
|
const existing = await db.query<any[]>(
|
||||||
|
`SELECT sr.*, d.id as department_id
|
||||||
FROM standard_rates sr
|
FROM standard_rates sr
|
||||||
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
|
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
|
||||||
LEFT JOIN departments d ON sd.department_id = d.id
|
LEFT JOIN departments d ON sd.department_id = d.id
|
||||||
WHERE sr.id = ?`,
|
WHERE sr.id = ?`,
|
||||||
[rateId]
|
[rateId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Standard rate not found" };
|
ctx.response.body = { error: "Standard rate not found" };
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supervisors can only delete rates in their department
|
||||||
|
if (
|
||||||
|
currentUser.role === "Supervisor" &&
|
||||||
|
existing[0].department_id !== currentUser.departmentId
|
||||||
|
) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: "Access denied - rate not in your department",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute("DELETE FROM standard_rates WHERE id = ?", [rateId]);
|
||||||
|
ctx.response.body = { message: "Standard rate deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete standard rate error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// Supervisors can only delete rates in their department
|
);
|
||||||
if (currentUser.role === "Supervisor" && existing[0].department_id !== currentUser.departmentId) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Access denied - rate not in your department" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.execute("DELETE FROM standard_rates WHERE id = ?", [rateId]);
|
|
||||||
ctx.response.body = { message: "Standard rate deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete standard rate error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { type Context, Router } from "@oak/oak";
|
||||||
import { hash, genSalt } from "bcrypt";
|
import { genSalt, hash } from "bcrypt";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { config } from "../config/env.ts";
|
import { config } from "../config/env.ts";
|
||||||
|
|
||||||
@@ -8,20 +8,28 @@ async function hashPassword(password: string): Promise<string> {
|
|||||||
const salt = await genSalt(config.BCRYPT_ROUNDS);
|
const salt = await genSalt(config.BCRYPT_ROUNDS);
|
||||||
return await hash(password, salt);
|
return await hash(password, salt);
|
||||||
}
|
}
|
||||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
import { sanitizeInput, isValidEmail } from "../middleware/security.ts";
|
authenticateToken,
|
||||||
import type { User, CreateUserRequest, UpdateUserRequest } from "../types/index.ts";
|
authorize,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
|
import { isValidEmail, sanitizeInput } from "../middleware/security.ts";
|
||||||
|
import type {
|
||||||
|
CreateUserRequest,
|
||||||
|
UpdateUserRequest,
|
||||||
|
User,
|
||||||
|
} from "../types/index.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
// Get all users (with filters)
|
// Get all users (with filters)
|
||||||
router.get("/", authenticateToken, async (ctx) => {
|
router.get("/", authenticateToken, async (ctx: Context) => {
|
||||||
try {
|
try {
|
||||||
const currentUser = getCurrentUser(ctx);
|
const currentUser = getCurrentUser(ctx);
|
||||||
const params = ctx.request.url.searchParams;
|
const params = ctx.request.url.searchParams;
|
||||||
const role = params.get("role");
|
const role = params.get("role");
|
||||||
const departmentId = params.get("departmentId");
|
const departmentId = params.get("departmentId");
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
||||||
u.contractor_id, u.is_active, u.created_at,
|
u.contractor_id, u.is_active, u.created_at,
|
||||||
@@ -36,25 +44,25 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|
||||||
// Supervisors can only see users in their department
|
// Supervisors can only see users in their department
|
||||||
if (currentUser.role === "Supervisor") {
|
if (currentUser.role === "Supervisor") {
|
||||||
query += " AND u.department_id = ?";
|
query += " AND u.department_id = ?";
|
||||||
queryParams.push(currentUser.departmentId);
|
queryParams.push(currentUser.departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role) {
|
if (role) {
|
||||||
query += " AND u.role = ?";
|
query += " AND u.role = ?";
|
||||||
queryParams.push(role);
|
queryParams.push(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (departmentId) {
|
if (departmentId) {
|
||||||
query += " AND u.department_id = ?";
|
query += " AND u.department_id = ?";
|
||||||
queryParams.push(departmentId);
|
queryParams.push(departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
query += " ORDER BY u.created_at DESC";
|
query += " ORDER BY u.created_at DESC";
|
||||||
|
|
||||||
const users = await db.query<User[]>(query, queryParams);
|
const users = await db.query<User[]>(query, queryParams);
|
||||||
ctx.response.body = users;
|
ctx.response.body = users;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,11 +73,11 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get user by ID
|
// Get user by ID
|
||||||
router.get("/:id", authenticateToken, async (ctx) => {
|
router.get("/:id", authenticateToken, async (ctx: Context) => {
|
||||||
try {
|
try {
|
||||||
const currentUser = getCurrentUser(ctx);
|
const currentUser = getCurrentUser(ctx);
|
||||||
const userId = ctx.params.id;
|
const userId = ctx.params.id;
|
||||||
|
|
||||||
const users = await db.query<User[]>(
|
const users = await db.query<User[]>(
|
||||||
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
||||||
u.contractor_id, u.is_active, u.created_at,
|
u.contractor_id, u.is_active, u.created_at,
|
||||||
@@ -82,22 +90,25 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
|||||||
LEFT JOIN departments d ON u.department_id = d.id
|
LEFT JOIN departments d ON u.department_id = d.id
|
||||||
LEFT JOIN users c ON u.contractor_id = c.id
|
LEFT JOIN users c ON u.contractor_id = c.id
|
||||||
WHERE u.id = ?`,
|
WHERE u.id = ?`,
|
||||||
[userId]
|
[userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "User not found" };
|
ctx.response.body = { error: "User not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supervisors can only view users in their department
|
// Supervisors can only view users in their department
|
||||||
if (currentUser.role === "Supervisor" && users[0].department_id !== currentUser.departmentId) {
|
if (
|
||||||
|
currentUser.role === "Supervisor" &&
|
||||||
|
users[0].department_id !== currentUser.departmentId
|
||||||
|
) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = { error: "Access denied" };
|
ctx.response.body = { error: "Access denied" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.body = users[0];
|
ctx.response.body = users[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get user error:", error);
|
console.error("Get user error:", error);
|
||||||
@@ -107,215 +118,98 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
router.post("/", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
|
router.post(
|
||||||
try {
|
"/",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const body = await ctx.request.body.json() as CreateUserRequest;
|
authorize("SuperAdmin", "Supervisor"),
|
||||||
const {
|
async (ctx: Context) => {
|
||||||
username, name, email, password, role, departmentId, contractorId,
|
try {
|
||||||
phoneNumber, aadharNumber, bankAccountNumber, bankName, bankIfsc,
|
const currentUser = getCurrentUser(ctx);
|
||||||
contractorAgreementNumber, pfNumber, esicNumber
|
const body = await ctx.request.body.json() as CreateUserRequest;
|
||||||
} = body;
|
const {
|
||||||
|
username,
|
||||||
// Input validation
|
name,
|
||||||
if (!username || !name || !email || !password || !role) {
|
email,
|
||||||
ctx.response.status = 400;
|
password,
|
||||||
ctx.response.body = { error: "Missing required fields" };
|
role,
|
||||||
return;
|
departmentId,
|
||||||
}
|
contractorId,
|
||||||
|
phoneNumber,
|
||||||
// Sanitize inputs
|
aadharNumber,
|
||||||
const sanitizedUsername = sanitizeInput(username);
|
bankAccountNumber,
|
||||||
const sanitizedName = sanitizeInput(name);
|
bankName,
|
||||||
const sanitizedEmail = sanitizeInput(email);
|
bankIfsc,
|
||||||
|
contractorAgreementNumber,
|
||||||
// Validate email
|
pfNumber,
|
||||||
if (!isValidEmail(sanitizedEmail)) {
|
esicNumber,
|
||||||
ctx.response.status = 400;
|
} = body;
|
||||||
ctx.response.body = { error: "Invalid email format" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supervisors can only create users in their department
|
|
||||||
if (currentUser.role === "Supervisor") {
|
|
||||||
if (departmentId !== currentUser.departmentId) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Can only create users in your department" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (role === "SuperAdmin" || role === "Supervisor") {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Cannot create admin or supervisor users" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
const hashedPassword = await hashPassword(password);
|
|
||||||
|
|
||||||
const result = await db.execute(
|
|
||||||
`INSERT INTO users (username, name, email, password, role, department_id, contractor_id,
|
|
||||||
phone_number, aadhar_number, bank_account_number, bank_name, bank_ifsc,
|
|
||||||
contractor_agreement_number, pf_number, esic_number)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
sanitizedUsername, sanitizedName, sanitizedEmail, hashedPassword, role,
|
|
||||||
departmentId || null, contractorId || null,
|
|
||||||
phoneNumber || null, aadharNumber || null, bankAccountNumber || null,
|
|
||||||
bankName || null, bankIfsc || null,
|
|
||||||
contractorAgreementNumber || null, pfNumber || null, esicNumber || null
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newUser = await db.query<User[]>(
|
|
||||||
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
|
||||||
u.contractor_id, u.is_active, u.created_at,
|
|
||||||
u.phone_number, u.aadhar_number, u.bank_account_number,
|
|
||||||
u.bank_name, u.bank_ifsc,
|
|
||||||
u.contractor_agreement_number, u.pf_number, u.esic_number,
|
|
||||||
d.name as department_name,
|
|
||||||
c.name as contractor_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN departments d ON u.department_id = d.id
|
|
||||||
LEFT JOIN users c ON u.contractor_id = c.id
|
|
||||||
WHERE u.id = ?`,
|
|
||||||
[result.insertId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = newUser[0];
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as { code?: string };
|
|
||||||
if (err.code === "ER_DUP_ENTRY") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Username or email already exists" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error("Create user error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update user
|
// Input validation
|
||||||
router.put("/:id", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
|
if (!username || !name || !email || !password || !role) {
|
||||||
try {
|
ctx.response.status = 400;
|
||||||
const currentUser = getCurrentUser(ctx);
|
ctx.response.body = { error: "Missing required fields" };
|
||||||
const userId = ctx.params.id;
|
|
||||||
const body = await ctx.request.body.json() as UpdateUserRequest;
|
|
||||||
const {
|
|
||||||
name, email, role, departmentId, contractorId, isActive,
|
|
||||||
phoneNumber, aadharNumber, bankAccountNumber, bankName, bankIfsc,
|
|
||||||
contractorAgreementNumber, pfNumber, esicNumber
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
const existingUsers = await db.query<User[]>(
|
|
||||||
"SELECT * FROM users WHERE id = ?",
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingUsers.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { error: "User not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supervisors can only update users in their department
|
|
||||||
if (currentUser.role === "Supervisor") {
|
|
||||||
if (existingUsers[0].department_id !== currentUser.departmentId) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Can only update users in your department" };
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (role === "SuperAdmin" || role === "Supervisor") {
|
|
||||||
ctx.response.status = 403;
|
// Sanitize inputs
|
||||||
ctx.response.body = { error: "Cannot modify admin or supervisor roles" };
|
const sanitizedUsername = sanitizeInput(username);
|
||||||
return;
|
const sanitizedName = sanitizeInput(name);
|
||||||
}
|
const sanitizedEmail = sanitizeInput(email);
|
||||||
}
|
|
||||||
|
// Validate email
|
||||||
const updates: string[] = [];
|
if (!isValidEmail(sanitizedEmail)) {
|
||||||
const params: unknown[] = [];
|
|
||||||
|
|
||||||
if (name !== undefined) {
|
|
||||||
updates.push("name = ?");
|
|
||||||
params.push(sanitizeInput(name));
|
|
||||||
}
|
|
||||||
if (email !== undefined) {
|
|
||||||
if (!isValidEmail(email)) {
|
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Invalid email format" };
|
ctx.response.body = { error: "Invalid email format" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updates.push("email = ?");
|
|
||||||
params.push(sanitizeInput(email));
|
// Supervisors can only create users in their department
|
||||||
}
|
if (currentUser.role === "Supervisor") {
|
||||||
if (role !== undefined) {
|
if (departmentId !== currentUser.departmentId) {
|
||||||
updates.push("role = ?");
|
ctx.response.status = 403;
|
||||||
params.push(role);
|
ctx.response.body = {
|
||||||
}
|
error: "Can only create users in your department",
|
||||||
if (departmentId !== undefined) {
|
};
|
||||||
updates.push("department_id = ?");
|
return;
|
||||||
params.push(departmentId);
|
}
|
||||||
}
|
if (role === "SuperAdmin" || role === "Supervisor") {
|
||||||
if (contractorId !== undefined) {
|
ctx.response.status = 403;
|
||||||
updates.push("contractor_id = ?");
|
ctx.response.body = {
|
||||||
params.push(contractorId);
|
error: "Cannot create admin or supervisor users",
|
||||||
}
|
};
|
||||||
if (isActive !== undefined) {
|
return;
|
||||||
updates.push("is_active = ?");
|
}
|
||||||
params.push(isActive);
|
}
|
||||||
}
|
|
||||||
// New fields
|
// Hash password
|
||||||
if (phoneNumber !== undefined) {
|
const hashedPassword = await hashPassword(password);
|
||||||
updates.push("phone_number = ?");
|
|
||||||
params.push(phoneNumber);
|
const result = await db.execute(
|
||||||
}
|
`INSERT INTO users (username, name, email, password, role, department_id, contractor_id,
|
||||||
if (aadharNumber !== undefined) {
|
phone_number, aadhar_number, bank_account_number, bank_name, bank_ifsc,
|
||||||
updates.push("aadhar_number = ?");
|
contractor_agreement_number, pf_number, esic_number)
|
||||||
params.push(aadharNumber);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
}
|
[
|
||||||
if (bankAccountNumber !== undefined) {
|
sanitizedUsername,
|
||||||
updates.push("bank_account_number = ?");
|
sanitizedName,
|
||||||
params.push(bankAccountNumber);
|
sanitizedEmail,
|
||||||
}
|
hashedPassword,
|
||||||
if (bankName !== undefined) {
|
role,
|
||||||
updates.push("bank_name = ?");
|
departmentId || null,
|
||||||
params.push(bankName);
|
contractorId || null,
|
||||||
}
|
phoneNumber || null,
|
||||||
if (bankIfsc !== undefined) {
|
aadharNumber || null,
|
||||||
updates.push("bank_ifsc = ?");
|
bankAccountNumber || null,
|
||||||
params.push(bankIfsc);
|
bankName || null,
|
||||||
}
|
bankIfsc || null,
|
||||||
if (contractorAgreementNumber !== undefined) {
|
contractorAgreementNumber || null,
|
||||||
updates.push("contractor_agreement_number = ?");
|
pfNumber || null,
|
||||||
params.push(contractorAgreementNumber);
|
esicNumber || null,
|
||||||
}
|
],
|
||||||
if (pfNumber !== undefined) {
|
);
|
||||||
updates.push("pf_number = ?");
|
|
||||||
params.push(pfNumber);
|
const newUser = await db.query<User[]>(
|
||||||
}
|
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
||||||
if (esicNumber !== undefined) {
|
|
||||||
updates.push("esic_number = ?");
|
|
||||||
params.push(esicNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "No fields to update" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
params.push(userId);
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
`UPDATE users SET ${updates.join(", ")} WHERE id = ?`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedUser = await db.query<User[]>(
|
|
||||||
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
|
||||||
u.contractor_id, u.is_active, u.created_at,
|
u.contractor_id, u.is_active, u.created_at,
|
||||||
u.phone_number, u.aadhar_number, u.bank_account_number,
|
u.phone_number, u.aadhar_number, u.bank_account_number,
|
||||||
u.bank_name, u.bank_ifsc,
|
u.bank_name, u.bank_ifsc,
|
||||||
@@ -326,55 +220,232 @@ router.put("/:id", authenticateToken, authorize("SuperAdmin", "Supervisor"), asy
|
|||||||
LEFT JOIN departments d ON u.department_id = d.id
|
LEFT JOIN departments d ON u.department_id = d.id
|
||||||
LEFT JOIN users c ON u.contractor_id = c.id
|
LEFT JOIN users c ON u.contractor_id = c.id
|
||||||
WHERE u.id = ?`,
|
WHERE u.id = ?`,
|
||||||
[userId]
|
[result.insertId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.body = updatedUser[0];
|
ctx.response.status = 201;
|
||||||
} catch (error) {
|
ctx.response.body = newUser[0];
|
||||||
console.error("Update user error:", error);
|
} catch (error) {
|
||||||
ctx.response.status = 500;
|
const err = error as { code?: string };
|
||||||
ctx.response.body = { error: "Internal server error" };
|
if (err.code === "ER_DUP_ENTRY") {
|
||||||
}
|
ctx.response.status = 400;
|
||||||
});
|
ctx.response.body = { error: "Username or email already exists" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Create user error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
router.put(
|
||||||
|
"/:id",
|
||||||
|
authenticateToken,
|
||||||
|
authorize("SuperAdmin", "Supervisor"),
|
||||||
|
async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const currentUser = getCurrentUser(ctx);
|
||||||
|
const userId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json() as UpdateUserRequest;
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
departmentId,
|
||||||
|
contractorId,
|
||||||
|
isActive,
|
||||||
|
phoneNumber,
|
||||||
|
aadharNumber,
|
||||||
|
bankAccountNumber,
|
||||||
|
bankName,
|
||||||
|
bankIfsc,
|
||||||
|
contractorAgreementNumber,
|
||||||
|
pfNumber,
|
||||||
|
esicNumber,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const existingUsers = await db.query<User[]>(
|
||||||
|
"SELECT * FROM users WHERE id = ?",
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUsers.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { error: "User not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supervisors can only update users in their department
|
||||||
|
if (currentUser.role === "Supervisor") {
|
||||||
|
if (existingUsers[0].department_id !== currentUser.departmentId) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: "Can only update users in your department",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (role === "SuperAdmin" || role === "Supervisor") {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: "Cannot modify admin or supervisor roles",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
updates.push("name = ?");
|
||||||
|
params.push(sanitizeInput(name));
|
||||||
|
}
|
||||||
|
if (email !== undefined) {
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Invalid email format" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updates.push("email = ?");
|
||||||
|
params.push(sanitizeInput(email));
|
||||||
|
}
|
||||||
|
if (role !== undefined) {
|
||||||
|
updates.push("role = ?");
|
||||||
|
params.push(role);
|
||||||
|
}
|
||||||
|
if (departmentId !== undefined) {
|
||||||
|
updates.push("department_id = ?");
|
||||||
|
params.push(departmentId);
|
||||||
|
}
|
||||||
|
if (contractorId !== undefined) {
|
||||||
|
updates.push("contractor_id = ?");
|
||||||
|
params.push(contractorId);
|
||||||
|
}
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
updates.push("is_active = ?");
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
// New fields
|
||||||
|
if (phoneNumber !== undefined) {
|
||||||
|
updates.push("phone_number = ?");
|
||||||
|
params.push(phoneNumber);
|
||||||
|
}
|
||||||
|
if (aadharNumber !== undefined) {
|
||||||
|
updates.push("aadhar_number = ?");
|
||||||
|
params.push(aadharNumber);
|
||||||
|
}
|
||||||
|
if (bankAccountNumber !== undefined) {
|
||||||
|
updates.push("bank_account_number = ?");
|
||||||
|
params.push(bankAccountNumber);
|
||||||
|
}
|
||||||
|
if (bankName !== undefined) {
|
||||||
|
updates.push("bank_name = ?");
|
||||||
|
params.push(bankName);
|
||||||
|
}
|
||||||
|
if (bankIfsc !== undefined) {
|
||||||
|
updates.push("bank_ifsc = ?");
|
||||||
|
params.push(bankIfsc);
|
||||||
|
}
|
||||||
|
if (contractorAgreementNumber !== undefined) {
|
||||||
|
updates.push("contractor_agreement_number = ?");
|
||||||
|
params.push(contractorAgreementNumber);
|
||||||
|
}
|
||||||
|
if (pfNumber !== undefined) {
|
||||||
|
updates.push("pf_number = ?");
|
||||||
|
params.push(pfNumber);
|
||||||
|
}
|
||||||
|
if (esicNumber !== undefined) {
|
||||||
|
updates.push("esic_number = ?");
|
||||||
|
params.push(esicNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "No fields to update" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(userId);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE users SET ${updates.join(", ")} WHERE id = ?`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedUser = await db.query<User[]>(
|
||||||
|
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
||||||
|
u.contractor_id, u.is_active, u.created_at,
|
||||||
|
u.phone_number, u.aadhar_number, u.bank_account_number,
|
||||||
|
u.bank_name, u.bank_ifsc,
|
||||||
|
u.contractor_agreement_number, u.pf_number, u.esic_number,
|
||||||
|
d.name as department_name,
|
||||||
|
c.name as contractor_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN departments d ON u.department_id = d.id
|
||||||
|
LEFT JOIN users c ON u.contractor_id = c.id
|
||||||
|
WHERE u.id = ?`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = updatedUser[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update user error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
router.delete("/:id", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
|
router.delete(
|
||||||
try {
|
"/:id",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const userId = ctx.params.id;
|
authorize("SuperAdmin", "Supervisor"),
|
||||||
|
async (ctx) => {
|
||||||
const users = await db.query<User[]>(
|
try {
|
||||||
"SELECT * FROM users WHERE id = ?",
|
const currentUser = getCurrentUser(ctx);
|
||||||
[userId]
|
const userId = ctx.params.id;
|
||||||
);
|
|
||||||
|
const users = await db.query<User[]>(
|
||||||
if (users.length === 0) {
|
"SELECT * FROM users WHERE id = ?",
|
||||||
ctx.response.status = 404;
|
[userId],
|
||||||
ctx.response.body = { error: "User not found" };
|
);
|
||||||
return;
|
|
||||||
}
|
if (users.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
// Supervisors can only delete users in their department
|
ctx.response.body = { error: "User not found" };
|
||||||
if (currentUser.role === "Supervisor") {
|
|
||||||
if (users[0].department_id !== currentUser.departmentId) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Can only delete users in your department" };
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (users[0].role === "SuperAdmin" || users[0].role === "Supervisor") {
|
|
||||||
ctx.response.status = 403;
|
// Supervisors can only delete users in their department
|
||||||
ctx.response.body = { error: "Cannot delete admin or supervisor users" };
|
if (currentUser.role === "Supervisor") {
|
||||||
return;
|
if (users[0].department_id !== currentUser.departmentId) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: "Can only delete users in your department",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (users[0].role === "SuperAdmin" || users[0].role === "Supervisor") {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: "Cannot delete admin or supervisor users",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await db.execute("DELETE FROM users WHERE id = ?", [userId]);
|
||||||
|
ctx.response.body = { message: "User deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete user error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
await db.execute("DELETE FROM users WHERE id = ?", [userId]);
|
);
|
||||||
ctx.response.body = { message: "User deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete user error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router, type RouterContext, type State } from "@oak/oak";
|
||||||
import { db } from "../config/database.ts";
|
import { db } from "../config/database.ts";
|
||||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
import {
|
||||||
|
authenticateToken,
|
||||||
|
authorize,
|
||||||
|
getCurrentUser,
|
||||||
|
} from "../middleware/auth.ts";
|
||||||
import { sanitizeInput } from "../middleware/security.ts";
|
import { sanitizeInput } from "../middleware/security.ts";
|
||||||
import type { WorkAllocation, CreateWorkAllocationRequest, ContractorRate } from "../types/index.ts";
|
import type {
|
||||||
|
ContractorRate,
|
||||||
|
CreateWorkAllocationRequest,
|
||||||
|
JWTPayload,
|
||||||
|
WorkAllocation,
|
||||||
|
} from "../types/index.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
// Get all work allocations
|
// Get all work allocations
|
||||||
router.get("/", authenticateToken, async (ctx) => {
|
router.get(
|
||||||
try {
|
"/",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const params = ctx.request.url.searchParams;
|
async (
|
||||||
const employeeId = params.get("employeeId");
|
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
|
||||||
const status = params.get("status");
|
) => {
|
||||||
const departmentId = params.get("departmentId");
|
try {
|
||||||
|
const currentUser: JWTPayload = getCurrentUser(ctx);
|
||||||
let query = `
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
||||||
|
const employeeId: string | null = params.get("employeeId");
|
||||||
|
const status: string | null = params.get("status");
|
||||||
|
const departmentId: string | null = params.get("departmentId");
|
||||||
|
|
||||||
|
let query: string = `
|
||||||
SELECT wa.*,
|
SELECT wa.*,
|
||||||
e.name as employee_name, e.username as employee_username,
|
e.name as employee_name, e.username as employee_username,
|
||||||
s.name as supervisor_name,
|
s.name as supervisor_name,
|
||||||
@@ -30,52 +44,53 @@ router.get("/", authenticateToken, async (ctx) => {
|
|||||||
LEFT JOIN departments d ON e.department_id = d.id
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|
||||||
// Role-based filtering
|
// Role-based filtering
|
||||||
if (currentUser.role === "Supervisor") {
|
if (currentUser.role === "Supervisor") {
|
||||||
query += " AND wa.supervisor_id = ?";
|
query += " AND wa.supervisor_id = ?";
|
||||||
queryParams.push(currentUser.id);
|
queryParams.push(currentUser.id);
|
||||||
} else if (currentUser.role === "Employee") {
|
} else if (currentUser.role === "Employee") {
|
||||||
query += " AND wa.employee_id = ?";
|
query += " AND wa.employee_id = ?";
|
||||||
queryParams.push(currentUser.id);
|
queryParams.push(currentUser.id);
|
||||||
} else if (currentUser.role === "Contractor") {
|
} else if (currentUser.role === "Contractor") {
|
||||||
query += " AND wa.contractor_id = ?";
|
query += " AND wa.contractor_id = ?";
|
||||||
queryParams.push(currentUser.id);
|
queryParams.push(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employeeId) {
|
||||||
|
query += " AND wa.employee_id = ?";
|
||||||
|
queryParams.push(employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += " AND wa.status = ?";
|
||||||
|
queryParams.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (departmentId) {
|
||||||
|
query += " AND e.department_id = ?";
|
||||||
|
queryParams.push(departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY wa.assigned_date DESC, wa.created_at DESC";
|
||||||
|
|
||||||
|
const allocations = await db.query<WorkAllocation[]>(query, queryParams);
|
||||||
|
ctx.response.body = allocations;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get work allocations error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
if (employeeId) {
|
);
|
||||||
query += " AND wa.employee_id = ?";
|
|
||||||
queryParams.push(employeeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
query += " AND wa.status = ?";
|
|
||||||
queryParams.push(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (departmentId) {
|
|
||||||
query += " AND e.department_id = ?";
|
|
||||||
queryParams.push(departmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY wa.assigned_date DESC, wa.created_at DESC";
|
|
||||||
|
|
||||||
const allocations = await db.query<WorkAllocation[]>(query, queryParams);
|
|
||||||
ctx.response.body = allocations;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get work allocations error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get work allocation by ID
|
// Get work allocation by ID
|
||||||
router.get("/:id", authenticateToken, async (ctx) => {
|
router.get("/:id", authenticateToken, async (ctx: RouterContext<"/:id">) => {
|
||||||
try {
|
try {
|
||||||
const allocationId = ctx.params.id;
|
const allocationId: string | undefined = ctx.params.id;
|
||||||
|
|
||||||
const allocations = await db.query<WorkAllocation[]>(
|
const allocations: WorkAllocation[] = await db.query<WorkAllocation[]>(
|
||||||
`SELECT wa.*,
|
`SELECT wa.*,
|
||||||
e.name as employee_name, e.username as employee_username,
|
e.name as employee_name, e.username as employee_username,
|
||||||
s.name as supervisor_name,
|
s.name as supervisor_name,
|
||||||
@@ -89,15 +104,15 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
|||||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
||||||
LEFT JOIN departments d ON e.department_id = d.id
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
WHERE wa.id = ?`,
|
WHERE wa.id = ?`,
|
||||||
[allocationId]
|
[allocationId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allocations.length === 0) {
|
if (allocations.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { error: "Work allocation not found" };
|
ctx.response.body = { error: "Work allocation not found" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.body = allocations[0];
|
ctx.response.body = allocations[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get work allocation error:", error);
|
console.error("Get work allocation error:", error);
|
||||||
@@ -107,57 +122,92 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create work allocation (Supervisor or SuperAdmin)
|
// Create work allocation (Supervisor or SuperAdmin)
|
||||||
router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.post(
|
||||||
try {
|
"/",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const body = await ctx.request.body.json() as CreateWorkAllocationRequest;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const { employeeId, contractorId, subDepartmentId, activity, description, assignedDate, rate, units, totalAmount, departmentId } = body;
|
async (
|
||||||
|
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
|
||||||
if (!employeeId || !contractorId || !assignedDate) {
|
) => {
|
||||||
ctx.response.status = 400;
|
try {
|
||||||
ctx.response.body = { error: "Missing required fields" };
|
const currentUser = getCurrentUser(ctx);
|
||||||
return;
|
const body = await ctx.request.body.json() as CreateWorkAllocationRequest;
|
||||||
}
|
const {
|
||||||
|
employeeId,
|
||||||
// Verify employee exists
|
contractorId,
|
||||||
let employeeQuery = "SELECT * FROM users WHERE id = ?";
|
subDepartmentId,
|
||||||
const employeeParams: unknown[] = [employeeId];
|
activity,
|
||||||
|
description,
|
||||||
if (currentUser.role === "Supervisor") {
|
assignedDate,
|
||||||
employeeQuery += " AND department_id = ?";
|
rate,
|
||||||
employeeParams.push(currentUser.departmentId);
|
units,
|
||||||
}
|
totalAmount,
|
||||||
|
departmentId,
|
||||||
const employees = await db.query<{ id: number }[]>(employeeQuery, employeeParams);
|
} = body;
|
||||||
|
|
||||||
if (employees.length === 0) {
|
if (!employeeId || !contractorId || !assignedDate) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { error: "Employee not found or not in your department" };
|
ctx.response.body = { error: "Missing required fields" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use provided rate or get contractor's current rate
|
// Verify employee exists
|
||||||
let finalRate = rate;
|
let employeeQuery = "SELECT * FROM users WHERE id = ?";
|
||||||
if (!finalRate) {
|
const employeeParams: unknown[] = [employeeId];
|
||||||
const rates = await db.query<ContractorRate[]>(
|
|
||||||
"SELECT rate FROM contractor_rates WHERE contractor_id = ? ORDER BY effective_date DESC LIMIT 1",
|
if (currentUser.role === "Supervisor") {
|
||||||
[contractorId]
|
employeeQuery += " AND department_id = ?";
|
||||||
|
employeeParams.push(currentUser.departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employees = await db.query<{ id: number }[]>(
|
||||||
|
employeeQuery,
|
||||||
|
employeeParams,
|
||||||
);
|
);
|
||||||
finalRate = rates.length > 0 ? rates[0].rate : null;
|
|
||||||
}
|
if (employees.length === 0) {
|
||||||
|
ctx.response.status = 403;
|
||||||
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
|
ctx.response.body = {
|
||||||
const sanitizedDescription = description ? sanitizeInput(description) : null;
|
error: "Employee not found or not in your department",
|
||||||
|
};
|
||||||
const result = await db.execute(
|
return;
|
||||||
`INSERT INTO work_allocations
|
}
|
||||||
|
|
||||||
|
// Use provided rate or get contractor's current rate
|
||||||
|
let finalRate = rate;
|
||||||
|
if (!finalRate) {
|
||||||
|
const rates = await db.query<ContractorRate[]>(
|
||||||
|
"SELECT rate FROM contractor_rates WHERE contractor_id = ? ORDER BY effective_date DESC LIMIT 1",
|
||||||
|
[contractorId],
|
||||||
|
);
|
||||||
|
finalRate = rates.length > 0 ? rates[0].rate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
|
||||||
|
const sanitizedDescription = description
|
||||||
|
? sanitizeInput(description)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const result = await db.execute(
|
||||||
|
`INSERT INTO work_allocations
|
||||||
(employee_id, supervisor_id, contractor_id, sub_department_id, activity, description, assigned_date, rate, units, total_amount)
|
(employee_id, supervisor_id, contractor_id, sub_department_id, activity, description, assigned_date, rate, units, total_amount)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[employeeId, currentUser.id, contractorId, subDepartmentId || null, sanitizedActivity, sanitizedDescription, assignedDate, finalRate, units || null, totalAmount || null]
|
[
|
||||||
);
|
employeeId,
|
||||||
|
currentUser.id,
|
||||||
const newAllocation = await db.query<WorkAllocation[]>(
|
contractorId,
|
||||||
`SELECT wa.*,
|
subDepartmentId || null,
|
||||||
|
sanitizedActivity,
|
||||||
|
sanitizedDescription,
|
||||||
|
assignedDate,
|
||||||
|
finalRate,
|
||||||
|
units || null,
|
||||||
|
totalAmount || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newAllocation = await db.query<WorkAllocation[]>(
|
||||||
|
`SELECT wa.*,
|
||||||
e.name as employee_name, e.username as employee_username,
|
e.name as employee_name, e.username as employee_username,
|
||||||
s.name as supervisor_name,
|
s.name as supervisor_name,
|
||||||
c.name as contractor_name,
|
c.name as contractor_name,
|
||||||
@@ -170,56 +220,66 @@ router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async
|
|||||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
||||||
LEFT JOIN departments d ON e.department_id = d.id
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
WHERE wa.id = ?`,
|
WHERE wa.id = ?`,
|
||||||
[result.insertId]
|
[result.insertId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = newAllocation[0];
|
ctx.response.body = newAllocation[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create work allocation error:", error);
|
console.error("Create work allocation error:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
ctx.response.body = { error: "Internal server error" };
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update work allocation status (Supervisor or SuperAdmin)
|
// Update work allocation status (Supervisor or SuperAdmin)
|
||||||
router.put("/:id/status", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.put(
|
||||||
try {
|
"/:id/status",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const allocationId = ctx.params.id;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
const body = await ctx.request.body.json() as { status: string; completionDate?: string };
|
async (ctx) => {
|
||||||
const { status, completionDate } = body;
|
try {
|
||||||
|
const currentUser = getCurrentUser(ctx);
|
||||||
if (!status) {
|
const allocationId = ctx.params.id;
|
||||||
ctx.response.status = 400;
|
const body = await ctx.request.body.json() as {
|
||||||
ctx.response.body = { error: "Status required" };
|
status: string;
|
||||||
return;
|
completionDate?: string;
|
||||||
}
|
};
|
||||||
|
const { status, completionDate } = body;
|
||||||
// Verify allocation exists and user has access
|
|
||||||
let query = "SELECT * FROM work_allocations WHERE id = ?";
|
if (!status) {
|
||||||
const params: unknown[] = [allocationId];
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Status required" };
|
||||||
if (currentUser.role === "Supervisor") {
|
return;
|
||||||
query += " AND supervisor_id = ?";
|
}
|
||||||
params.push(currentUser.id);
|
|
||||||
}
|
// Verify allocation exists and user has access
|
||||||
|
let query = "SELECT * FROM work_allocations WHERE id = ?";
|
||||||
const allocations = await db.query<WorkAllocation[]>(query, params);
|
const params: unknown[] = [allocationId];
|
||||||
|
|
||||||
if (allocations.length === 0) {
|
if (currentUser.role === "Supervisor") {
|
||||||
ctx.response.status = 403;
|
query += " AND supervisor_id = ?";
|
||||||
ctx.response.body = { error: "Work allocation not found or access denied" };
|
params.push(currentUser.id);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
const allocations = await db.query<WorkAllocation[]>(query, params);
|
||||||
await db.execute(
|
|
||||||
"UPDATE work_allocations SET status = ?, completion_date = ? WHERE id = ?",
|
if (allocations.length === 0) {
|
||||||
[status, completionDate || null, allocationId]
|
ctx.response.status = 403;
|
||||||
);
|
ctx.response.body = {
|
||||||
|
error: "Work allocation not found or access denied",
|
||||||
const updatedAllocation = await db.query<WorkAllocation[]>(
|
};
|
||||||
`SELECT wa.*,
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE work_allocations SET status = ?, completion_date = ? WHERE id = ?",
|
||||||
|
[status, completionDate || null, allocationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedAllocation = await db.query<WorkAllocation[]>(
|
||||||
|
`SELECT wa.*,
|
||||||
e.name as employee_name, e.username as employee_username,
|
e.name as employee_name, e.username as employee_username,
|
||||||
s.name as supervisor_name,
|
s.name as supervisor_name,
|
||||||
c.name as contractor_name,
|
c.name as contractor_name,
|
||||||
@@ -232,47 +292,57 @@ router.put("/:id/status", authenticateToken, authorize("Supervisor", "SuperAdmin
|
|||||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
||||||
LEFT JOIN departments d ON e.department_id = d.id
|
LEFT JOIN departments d ON e.department_id = d.id
|
||||||
WHERE wa.id = ?`,
|
WHERE wa.id = ?`,
|
||||||
[allocationId]
|
[allocationId],
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.response.body = updatedAllocation[0];
|
ctx.response.body = updatedAllocation[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Update work allocation error:", error);
|
console.error("Update work allocation error:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
ctx.response.body = { error: "Internal server error" };
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Delete work allocation (Supervisor or SuperAdmin)
|
// Delete work allocation (Supervisor or SuperAdmin)
|
||||||
router.delete("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
router.delete(
|
||||||
try {
|
"/:id",
|
||||||
const currentUser = getCurrentUser(ctx);
|
authenticateToken,
|
||||||
const allocationId = ctx.params.id;
|
authorize("Supervisor", "SuperAdmin"),
|
||||||
|
async (ctx) => {
|
||||||
// Verify allocation exists and user has access
|
try {
|
||||||
let query = "SELECT * FROM work_allocations WHERE id = ?";
|
const currentUser = getCurrentUser(ctx);
|
||||||
const params: unknown[] = [allocationId];
|
const allocationId = ctx.params.id;
|
||||||
|
|
||||||
if (currentUser.role === "Supervisor") {
|
// Verify allocation exists and user has access
|
||||||
query += " AND supervisor_id = ?";
|
let query = "SELECT * FROM work_allocations WHERE id = ?";
|
||||||
params.push(currentUser.id);
|
const params: unknown[] = [allocationId];
|
||||||
|
|
||||||
|
if (currentUser.role === "Supervisor") {
|
||||||
|
query += " AND supervisor_id = ?";
|
||||||
|
params.push(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allocations = await db.query<WorkAllocation[]>(query, params);
|
||||||
|
|
||||||
|
if (allocations.length === 0) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: "Work allocation not found or access denied",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute("DELETE FROM work_allocations WHERE id = ?", [
|
||||||
|
allocationId,
|
||||||
|
]);
|
||||||
|
ctx.response.body = { message: "Work allocation deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete work allocation error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const allocations = await db.query<WorkAllocation[]>(query, params);
|
);
|
||||||
|
|
||||||
if (allocations.length === 0) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { error: "Work allocation not found or access denied" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.execute("DELETE FROM work_allocations WHERE id = ?", [allocationId]);
|
|
||||||
ctx.response.body = { message: "Work allocation deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete work allocation error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { error: "Internal server error" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,11 @@ export interface SubDepartment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Work allocation types
|
// Work allocation types
|
||||||
export type AllocationStatus = "Pending" | "InProgress" | "Completed" | "Cancelled";
|
export type AllocationStatus =
|
||||||
|
| "Pending"
|
||||||
|
| "InProgress"
|
||||||
|
| "Completed"
|
||||||
|
| "Cancelled";
|
||||||
|
|
||||||
export interface WorkAllocation {
|
export interface WorkAllocation {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -76,7 +80,12 @@ export interface WorkAllocation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attendance types
|
// Attendance types
|
||||||
export type AttendanceStatus = "CheckedIn" | "CheckedOut" | "Absent" | "HalfDay" | "Late";
|
export type AttendanceStatus =
|
||||||
|
| "CheckedIn"
|
||||||
|
| "CheckedOut"
|
||||||
|
| "Absent"
|
||||||
|
| "HalfDay"
|
||||||
|
| "Late";
|
||||||
|
|
||||||
export interface Attendance {
|
export interface Attendance {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -12,7 +12,16 @@ services:
|
|||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
- ./backend/database/init-schema.sql:/docker-entrypoint-initdb.d/01-init-schema.sql:ro
|
- ./backend/database/init-schema.sql:/docker-entrypoint-initdb.d/01-init-schema.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-padmin123"]
|
test: [
|
||||||
|
"CMD",
|
||||||
|
"mysqladmin",
|
||||||
|
"ping",
|
||||||
|
"-h",
|
||||||
|
"localhost",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-padmin123",
|
||||||
|
]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js";
|
||||||
import globals from 'globals'
|
import globals from "globals";
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'node_modules', 'backend', 'backend-deno'] },
|
{ ignores: ["dist", "node_modules", "backend", "backend-deno"] },
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
@@ -16,21 +16,26 @@ export default tseslint.config(
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: "latest",
|
||||||
ecmaFeatures: { jsx: true },
|
ecmaFeatures: { tsx: true },
|
||||||
sourceType: 'module',
|
sourceType: "module",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
"react-hooks": reactHooks,
|
||||||
'react-refresh': reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", {
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }],
|
allowConstantExport: true,
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
}],
|
||||||
'no-unused-vars': 'off',
|
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
}],
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"no-unused-vars": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -8,10 +8,10 @@
|
|||||||
"name": "my-dashboard",
|
"name": "my-dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "0.555.0",
|
||||||
"react": "^19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"recharts": "^3.5.0",
|
"recharts": "3.5.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "0.555.0",
|
||||||
"react": "^19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"recharts": "^3.5.0",
|
"recharts": "3.5.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
2409
pnpm-lock.yaml
generated
2409
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,4 @@ export default {
|
|||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1 +1,40 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
class="iconify iconify--logos"
|
||||||
|
width="31.88"
|
||||||
|
height="32"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
viewBox="0 0 256 257"
|
||||||
|
>
|
||||||
|
<defs><linearGradient
|
||||||
|
id="IconifyId1813088fe1fbc01fb466"
|
||||||
|
x1="-.828%"
|
||||||
|
x2="57.636%"
|
||||||
|
y1="7.652%"
|
||||||
|
y2="78.411%"
|
||||||
|
><stop offset="0%" stop-color="#41D1FF"></stop><stop
|
||||||
|
offset="100%"
|
||||||
|
stop-color="#BD34FE"
|
||||||
|
></stop></linearGradient><linearGradient
|
||||||
|
id="IconifyId1813088fe1fbc01fb467"
|
||||||
|
x1="43.376%"
|
||||||
|
x2="50.316%"
|
||||||
|
y1="2.242%"
|
||||||
|
y2="89.03%"
|
||||||
|
><stop offset="0%" stop-color="#FFEA83"></stop><stop
|
||||||
|
offset="8.333%"
|
||||||
|
stop-color="#FFDD35"
|
||||||
|
></stop><stop
|
||||||
|
offset="100%"
|
||||||
|
stop-color="#FFA800"
|
||||||
|
></stop></linearGradient></defs><path
|
||||||
|
fill="url(#IconifyId1813088fe1fbc01fb466)"
|
||||||
|
d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"
|
||||||
|
></path><path
|
||||||
|
fill="url(#IconifyId1813088fe1fbc01fb467)"
|
||||||
|
d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
78
src/App.tsx
78
src/App.tsx
@@ -1,46 +1,56 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from "./contexts/AuthContext.tsx";
|
||||||
import { Sidebar } from './components/layout/Sidebar';
|
import { Sidebar } from "./components/layout/Sidebar.tsx";
|
||||||
import { Header } from './components/layout/Header';
|
import { Header } from "./components/layout/Header.tsx";
|
||||||
import { DashboardPage } from './pages/DashboardPage';
|
import { DashboardPage } from "./pages/DashboardPage.tsx";
|
||||||
import { UsersPage } from './pages/UsersPage';
|
import { UsersPage } from "./pages/UsersPage.tsx";
|
||||||
import { WorkAllocationPage } from './pages/WorkAllocationPage';
|
import { WorkAllocationPage } from "./pages/WorkAllocationPage.tsx";
|
||||||
import { AttendancePage } from './pages/AttendancePage';
|
import { AttendancePage } from "./pages/AttendancePage.tsx";
|
||||||
import { RatesPage } from './pages/RatesPage';
|
import { RatesPage } from "./pages/RatesPage.tsx";
|
||||||
import { EmployeeSwapPage } from './pages/EmployeeSwapPage';
|
import { EmployeeSwapPage } from "./pages/EmployeeSwapPage.tsx";
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from "./pages/LoginPage.tsx";
|
||||||
import { ReportingPage } from './pages/ReportingPage';
|
import { ReportingPage } from "./pages/ReportingPage.tsx";
|
||||||
import { StandardRatesPage } from './pages/StandardRatesPage';
|
import { StandardRatesPage } from "./pages/StandardRatesPage.tsx";
|
||||||
import { AllRatesPage } from './pages/AllRatesPage';
|
import { AllRatesPage } from "./pages/AllRatesPage.tsx";
|
||||||
import { ActivitiesPage } from './pages/ActivitiesPage';
|
import { ActivitiesPage } from "./pages/ActivitiesPage.tsx";
|
||||||
|
|
||||||
type PageType = 'dashboard' | 'users' | 'allocation' | 'attendance' | 'rates' | 'swaps' | 'reports' | 'standard-rates' | 'all-rates' | 'activities';
|
type PageType =
|
||||||
|
| "dashboard"
|
||||||
|
| "users"
|
||||||
|
| "allocation"
|
||||||
|
| "attendance"
|
||||||
|
| "rates"
|
||||||
|
| "swaps"
|
||||||
|
| "reports"
|
||||||
|
| "standard-rates"
|
||||||
|
| "all-rates"
|
||||||
|
| "activities";
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
const AppContent: React.FC = () => {
|
||||||
const [activePage, setActivePage] = useState<PageType>('dashboard');
|
const [activePage, setActivePage] = useState<PageType>("dashboard");
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
switch (activePage) {
|
switch (activePage) {
|
||||||
case 'dashboard':
|
case "dashboard":
|
||||||
return <DashboardPage />;
|
return <DashboardPage />;
|
||||||
case 'users':
|
case "users":
|
||||||
return <UsersPage />;
|
return <UsersPage />;
|
||||||
case 'allocation':
|
case "allocation":
|
||||||
return <WorkAllocationPage />;
|
return <WorkAllocationPage />;
|
||||||
case 'attendance':
|
case "attendance":
|
||||||
return <AttendancePage />;
|
return <AttendancePage />;
|
||||||
case 'rates':
|
case "rates":
|
||||||
return <RatesPage />;
|
return <RatesPage />;
|
||||||
case 'swaps':
|
case "swaps":
|
||||||
return <EmployeeSwapPage />;
|
return <EmployeeSwapPage />;
|
||||||
case 'reports':
|
case "reports":
|
||||||
return <ReportingPage />;
|
return <ReportingPage />;
|
||||||
case 'standard-rates':
|
case "standard-rates":
|
||||||
return <StandardRatesPage />;
|
return <StandardRatesPage />;
|
||||||
case 'all-rates':
|
case "all-rates":
|
||||||
return <AllRatesPage />;
|
return <AllRatesPage />;
|
||||||
case 'activities':
|
case "activities":
|
||||||
return <ActivitiesPage />;
|
return <ActivitiesPage />;
|
||||||
default:
|
default:
|
||||||
return <DashboardPage />;
|
return <DashboardPage />;
|
||||||
@@ -52,7 +62,8 @@ const AppContent: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-gray-100">
|
<div className="flex h-screen items-center justify-center bg-gray-100">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4">
|
||||||
|
</div>
|
||||||
<p className="text-gray-600">Loading...</p>
|
<p className="text-gray-600">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,11 +78,14 @@ const AppContent: React.FC = () => {
|
|||||||
// Show main app if authenticated
|
// Show main app if authenticated
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-100">
|
<div className="flex h-screen bg-gray-100">
|
||||||
<Sidebar activePage={activePage} onNavigate={(page) => setActivePage(page as PageType)} />
|
<Sidebar
|
||||||
|
activePage={activePage}
|
||||||
|
onNavigate={(page) => setActivePage(page as PageType)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
{renderPage()}
|
{renderPage()}
|
||||||
</main>
|
</main>
|
||||||
@@ -88,4 +102,4 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1 +1,16 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
class="iconify iconify--logos"
|
||||||
|
width="35.93"
|
||||||
|
height="32"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
viewBox="0 0 256 228"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#00D8FF"
|
||||||
|
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -1,9 +1,24 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { Bell, LogOut, X, Camera, Shield, User, Mail, Building2, ChevronDown, ChevronUp, Phone, CreditCard, Landmark, FileText } from 'lucide-react';
|
import {
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
Bell,
|
||||||
import { useDepartments } from '../../hooks/useDepartments';
|
Building2,
|
||||||
import { api } from '../../services/api';
|
Camera,
|
||||||
import type { User as UserType } from '../../types';
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
CreditCard,
|
||||||
|
FileText,
|
||||||
|
Landmark,
|
||||||
|
LogOut,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext.tsx";
|
||||||
|
import { useDepartments } from "../../hooks/useDepartments.ts";
|
||||||
|
import { api } from "../../services/api.ts";
|
||||||
|
import type { User as UserType } from "../../types.ts";
|
||||||
|
|
||||||
interface ProfilePopupProps {
|
interface ProfilePopupProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,47 +27,52 @@ interface ProfilePopupProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Permission definitions for each role
|
// Permission definitions for each role
|
||||||
const rolePermissions: Record<string, { title: string; permissions: string[] }> = {
|
const rolePermissions: Record<
|
||||||
|
string,
|
||||||
|
{ title: string; permissions: string[] }
|
||||||
|
> = {
|
||||||
Supervisor: {
|
Supervisor: {
|
||||||
title: 'Supervisor Permissions',
|
title: "Supervisor Permissions",
|
||||||
permissions: [
|
permissions: [
|
||||||
'View and manage employees in your department',
|
"View and manage employees in your department",
|
||||||
'Create and manage work allocations',
|
"Create and manage work allocations",
|
||||||
'Set contractor rates for your department',
|
"Set contractor rates for your department",
|
||||||
'View attendance records',
|
"View attendance records",
|
||||||
'Manage check-in/check-out for employees',
|
"Manage check-in/check-out for employees",
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
Employee: {
|
Employee: {
|
||||||
title: 'Employee Permissions',
|
title: "Employee Permissions",
|
||||||
permissions: [
|
permissions: [
|
||||||
'View your work allocations',
|
"View your work allocations",
|
||||||
'View your attendance records',
|
"View your attendance records",
|
||||||
'Check-in and check-out',
|
"Check-in and check-out",
|
||||||
'View assigned tasks',
|
"View assigned tasks",
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
Contractor: {
|
Contractor: {
|
||||||
title: 'Contractor Permissions',
|
title: "Contractor Permissions",
|
||||||
permissions: [
|
permissions: [
|
||||||
'View assigned work allocations',
|
"View assigned work allocations",
|
||||||
'View your rate configurations',
|
"View your rate configurations",
|
||||||
'Track work completion status',
|
"Track work completion status",
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
SuperAdmin: {
|
SuperAdmin: {
|
||||||
title: 'Super Admin Permissions',
|
title: "Super Admin Permissions",
|
||||||
permissions: [
|
permissions: [
|
||||||
'Full system access',
|
"Full system access",
|
||||||
'Manage all users and departments',
|
"Manage all users and departments",
|
||||||
'Configure all contractor rates',
|
"Configure all contractor rates",
|
||||||
'View all work allocations and reports',
|
"View all work allocations and reports",
|
||||||
'System configuration and settings',
|
"System configuration and settings",
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }) => {
|
const ProfilePopup: React.FC<ProfilePopupProps> = (
|
||||||
|
{ isOpen, onClose, onLogout },
|
||||||
|
) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { departments } = useDepartments();
|
const { departments } = useDepartments();
|
||||||
const [showPermissions, setShowPermissions] = useState(false);
|
const [showPermissions, setShowPermissions] = useState(false);
|
||||||
@@ -68,10 +88,11 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const userDepartment = departments.find(d => d.id === user?.department_id);
|
const userDepartment = departments.find((d) => d.id === user?.department_id);
|
||||||
const userPermissions = rolePermissions[user?.role || 'Employee'];
|
const userPermissions = rolePermissions[user?.role || "Employee"];
|
||||||
const isEmployeeOrContractor = user?.role === 'Employee' || user?.role === 'Contractor';
|
const isEmployeeOrContractor = user?.role === "Employee" ||
|
||||||
const isContractor = user?.role === 'Contractor';
|
user?.role === "Contractor";
|
||||||
|
const isContractor = user?.role === "Contractor";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-4 top-16 w-[400px] bg-gradient-to-b from-slate-50 to-white rounded-2xl shadow-2xl z-50 border border-gray-200 overflow-hidden font-sans text-sm text-gray-800 max-h-[85vh] overflow-y-auto">
|
<div className="absolute right-4 top-16 w-[400px] bg-gradient-to-b from-slate-50 to-white rounded-2xl shadow-2xl z-50 border border-gray-200 overflow-hidden font-sans text-sm text-gray-800 max-h-[85vh] overflow-y-auto">
|
||||||
@@ -79,22 +100,27 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
<div className="bg-gradient-to-r from-teal-600 to-teal-500 px-6 py-4">
|
<div className="bg-gradient-to-r from-teal-600 to-teal-500 px-6 py-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button onClick={onClose} className="text-white/80 hover:text-white hover:bg-white/20 rounded-full p-1 transition-colors">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/80 hover:text-white hover:bg-white/20 rounded-full p-1 transition-colors"
|
||||||
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center -mt-2">
|
<div className="flex flex-col items-center -mt-2">
|
||||||
<div className="relative mb-3">
|
<div className="relative mb-3">
|
||||||
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center text-teal-600 text-4xl font-bold shadow-lg">
|
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center text-teal-600 text-4xl font-bold shadow-lg">
|
||||||
{user?.name?.charAt(0).toUpperCase() || 'U'}
|
{user?.name?.charAt(0).toUpperCase() || "U"}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-0 right-0 bg-teal-700 rounded-full p-1.5 shadow-md cursor-pointer hover:bg-teal-800 transition-colors">
|
<div className="absolute bottom-0 right-0 bg-teal-700 rounded-full p-1.5 shadow-md cursor-pointer hover:bg-teal-800 transition-colors">
|
||||||
<Camera size={12} className="text-white" />
|
<Camera size={12} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl text-white font-semibold">Hi, {user?.name || 'User'}!</h3>
|
<h3 className="text-xl text-white font-semibold">
|
||||||
|
Hi, {user?.name || "User"}!
|
||||||
|
</h3>
|
||||||
<span className="mt-1 px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded-full uppercase tracking-wider">
|
<span className="mt-1 px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded-full uppercase tracking-wider">
|
||||||
{user?.role || 'User'}
|
{user?.role || "User"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +133,9 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 font-medium">Username</p>
|
<p className="text-xs text-gray-500 font-medium">Username</p>
|
||||||
<p className="text-sm font-semibold text-gray-800">{user?.username || 'N/A'}</p>
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
|
{user?.username || "N/A"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,18 +145,22 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs text-gray-500 font-medium">Email</p>
|
<p className="text-xs text-gray-500 font-medium">Email</p>
|
||||||
<p className="text-sm font-semibold text-gray-800 truncate">{user?.email || 'No email'}</p>
|
<p className="text-sm font-semibold text-gray-800 truncate">
|
||||||
|
{user?.email || "No email"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user?.role !== 'SuperAdmin' && userDepartment && (
|
{user?.role !== "SuperAdmin" && userDepartment && (
|
||||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
|
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
|
||||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
<Building2 size={18} className="text-green-600" />
|
<Building2 size={18} className="text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 font-medium">Department</p>
|
<p className="text-xs text-gray-500 font-medium">Department</p>
|
||||||
<p className="text-sm font-semibold text-gray-800">{userDepartment.name}</p>
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
|
{userDepartment.name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -144,11 +176,17 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
<CreditCard size={18} className="text-teal-600" />
|
<CreditCard size={18} className="text-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="text-xs text-gray-500 font-medium">Personal & Bank Details</p>
|
<p className="text-xs text-gray-500 font-medium">
|
||||||
<p className="text-sm font-semibold text-gray-800">View your information</p>
|
Personal & Bank Details
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
|
View your information
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showDetails ? <ChevronUp size={18} className="text-teal-600" /> : <ChevronDown size={18} className="text-teal-600" />}
|
{showDetails
|
||||||
|
? <ChevronUp size={18} className="text-teal-600" />
|
||||||
|
: <ChevronDown size={18} className="text-teal-600" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -162,14 +200,16 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Phone Number</span>
|
<span className="text-gray-600">Phone Number</span>
|
||||||
<span className="font-medium text-gray-800">{fullUserData.phone_number || 'Not provided'}</span>
|
<span className="font-medium text-gray-800">
|
||||||
|
{fullUserData.phone_number || "Not provided"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Aadhar Number</span>
|
<span className="text-gray-600">Aadhar Number</span>
|
||||||
<span className="font-medium text-gray-800">
|
<span className="font-medium text-gray-800">
|
||||||
{fullUserData.aadhar_number
|
{fullUserData.aadhar_number
|
||||||
? `XXXX-XXXX-${fullUserData.aadhar_number.slice(-4)}`
|
? `XXXX-XXXX-${fullUserData.aadhar_number.slice(-4)}`
|
||||||
: 'Not provided'}
|
: "Not provided"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,19 +223,23 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Bank Name</span>
|
<span className="text-gray-600">Bank Name</span>
|
||||||
<span className="font-medium text-gray-800">{fullUserData.bank_name || 'Not provided'}</span>
|
<span className="font-medium text-gray-800">
|
||||||
|
{fullUserData.bank_name || "Not provided"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Account Number</span>
|
<span className="text-gray-600">Account Number</span>
|
||||||
<span className="font-medium text-gray-800">
|
<span className="font-medium text-gray-800">
|
||||||
{fullUserData.bank_account_number
|
{fullUserData.bank_account_number
|
||||||
? `XXXX${fullUserData.bank_account_number.slice(-4)}`
|
? `XXXX${fullUserData.bank_account_number.slice(-4)}`
|
||||||
: 'Not provided'}
|
: "Not provided"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">IFSC Code</span>
|
<span className="text-gray-600">IFSC Code</span>
|
||||||
<span className="font-medium text-gray-800">{fullUserData.bank_ifsc || 'Not provided'}</span>
|
<span className="font-medium text-gray-800">
|
||||||
|
{fullUserData.bank_ifsc || "Not provided"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,15 +253,22 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Agreement No.</span>
|
<span className="text-gray-600">Agreement No.</span>
|
||||||
<span className="font-medium text-gray-800">{fullUserData.contractor_agreement_number || 'Not provided'}</span>
|
<span className="font-medium text-gray-800">
|
||||||
|
{fullUserData.contractor_agreement_number ||
|
||||||
|
"Not provided"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">PF Number</span>
|
<span className="text-gray-600">PF Number</span>
|
||||||
<span className="font-medium text-gray-800">{fullUserData.pf_number || 'Not provided'}</span>
|
<span className="font-medium text-gray-800">
|
||||||
|
{fullUserData.pf_number || "Not provided"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">ESIC Number</span>
|
<span className="text-gray-600">ESIC Number</span>
|
||||||
<span className="font-medium text-gray-800">{fullUserData.esic_number || 'Not provided'}</span>
|
<span className="font-medium text-gray-800">
|
||||||
|
{fullUserData.esic_number || "Not provided"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,19 +286,30 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
|
|||||||
<Shield size={18} className="text-amber-600" />
|
<Shield size={18} className="text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="text-xs text-gray-500 font-medium">Your Permissions</p>
|
<p className="text-xs text-gray-500 font-medium">
|
||||||
<p className="text-sm font-semibold text-gray-800">View what you can do</p>
|
Your Permissions
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
|
View what you can do
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showPermissions ? <ChevronUp size={18} className="text-amber-600" /> : <ChevronDown size={18} className="text-amber-600" />}
|
{showPermissions
|
||||||
|
? <ChevronUp size={18} className="text-amber-600" />
|
||||||
|
: <ChevronDown size={18} className="text-amber-600" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showPermissions && userPermissions && (
|
{showPermissions && userPermissions && (
|
||||||
<div className="bg-amber-50 rounded-xl p-4 border border-amber-200">
|
<div className="bg-amber-50 rounded-xl p-4 border border-amber-200">
|
||||||
<h4 className="font-semibold text-amber-800 mb-2">{userPermissions.title}</h4>
|
<h4 className="font-semibold text-amber-800 mb-2">
|
||||||
|
{userPermissions.title}
|
||||||
|
</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{userPermissions.permissions.map((perm, idx) => (
|
{userPermissions.permissions.map((perm, idx) => (
|
||||||
<li key={idx} className="flex items-start gap-2 text-sm text-amber-700">
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="flex items-start gap-2 text-sm text-amber-700"
|
||||||
|
>
|
||||||
<span className="text-amber-500 mt-0.5">•</span>
|
<span className="text-amber-500 mt-0.5">•</span>
|
||||||
{perm}
|
{perm}
|
||||||
</li>
|
</li>
|
||||||
@@ -284,18 +346,21 @@ export const Header: React.FC = () => {
|
|||||||
<header className="bg-white border-b border-gray-200 px-6 py-4 relative">
|
<header className="bg-white border-b border-gray-200 px-6 py-4 relative">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Work Allocation System</h1>
|
<h1 className="text-2xl font-bold text-gray-800">
|
||||||
|
Work Allocation System
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-full relative">
|
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-full relative">
|
||||||
<Bell size={20} />
|
<Bell size={20} />
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full">
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsProfileOpen(!isProfileOpen)}
|
onClick={() => setIsProfileOpen(!isProfileOpen)}
|
||||||
className="w-10 h-10 bg-teal-600 rounded-full flex items-center justify-center text-white font-medium hover:bg-teal-700"
|
className="w-10 h-10 bg-teal-600 rounded-full flex items-center justify-center text-white font-medium hover:bg-teal-700"
|
||||||
>
|
>
|
||||||
{user?.name?.charAt(0).toUpperCase() || 'U'}
|
{user?.name?.charAt(0).toUpperCase() || "U"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { LayoutDashboard, Users, Briefcase, CalendarCheck, DollarSign, ClipboardList, ArrowRightLeft, FileSpreadsheet, Scale, Eye, Layers } from 'lucide-react';
|
import {
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
ArrowRightLeft,
|
||||||
|
Briefcase,
|
||||||
|
CalendarCheck,
|
||||||
|
ClipboardList,
|
||||||
|
DollarSign,
|
||||||
|
Eye,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Layers,
|
||||||
|
LayoutDashboard,
|
||||||
|
Scale,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext.tsx";
|
||||||
|
|
||||||
interface SidebarItemProps {
|
interface SidebarItemProps {
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
@@ -9,13 +21,15 @@ interface SidebarItemProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarItem: React.FC<SidebarItemProps> = ({ icon: Icon, label, active, onClick }) => (
|
const SidebarItem: React.FC<SidebarItemProps> = (
|
||||||
|
{ icon: Icon, label, active, onClick },
|
||||||
|
) => (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`w-full flex items-center space-x-3 px-6 py-4 cursor-pointer transition-colors duration-200 outline-none focus:outline-none ${
|
className={`w-full flex items-center space-x-3 px-6 py-4 cursor-pointer transition-colors duration-200 outline-none focus:outline-none ${
|
||||||
active
|
active
|
||||||
? 'bg-blue-900 border-l-4 border-blue-400 text-white'
|
? "bg-blue-900 border-l-4 border-blue-400 text-white"
|
||||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white border-l-4 border-transparent'
|
: "text-gray-400 hover:bg-gray-800 hover:text-white border-l-4 border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
@@ -30,11 +44,11 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ activePage, onNavigate }) => {
|
export const Sidebar: React.FC<SidebarProps> = ({ activePage, onNavigate }) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isSuperAdmin = user?.role === 'SuperAdmin';
|
const isSuperAdmin = user?.role === "SuperAdmin";
|
||||||
const isSupervisor = user?.role === 'Supervisor';
|
const isSupervisor = user?.role === "Supervisor";
|
||||||
const isContractor = user?.role === 'Contractor';
|
const isContractor = user?.role === "Contractor";
|
||||||
const isEmployee = user?.role === 'Employee';
|
const isEmployee = user?.role === "Employee";
|
||||||
|
|
||||||
// Role-based access
|
// Role-based access
|
||||||
const canManageUsers = isSuperAdmin || isSupervisor;
|
const canManageUsers = isSuperAdmin || isSupervisor;
|
||||||
const canManageAllocations = isSuperAdmin || isSupervisor;
|
const canManageAllocations = isSuperAdmin || isSupervisor;
|
||||||
@@ -49,7 +63,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activePage, onNavigate }) => {
|
|||||||
<ClipboardList size={24} className="text-white" />
|
<ClipboardList size={24} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-white text-lg font-bold tracking-wide">Work Allocation</h1>
|
<h1 className="text-white text-lg font-bold tracking-wide">
|
||||||
|
Work Allocation
|
||||||
|
</h1>
|
||||||
<p className="text-gray-400 text-xs">Management System</p>
|
<p className="text-gray-400 text-xs">Management System</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,111 +75,120 @@ export const Sidebar: React.FC<SidebarProps> = ({ activePage, onNavigate }) => {
|
|||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
label="Dashboard"
|
label="Dashboard"
|
||||||
active={activePage === 'dashboard'}
|
active={activePage === "dashboard"}
|
||||||
onClick={() => onNavigate('dashboard')}
|
onClick={() => onNavigate("dashboard")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* User Management - SuperAdmin and Supervisor only */}
|
{/* User Management - SuperAdmin and Supervisor only */}
|
||||||
{canManageUsers && (
|
{canManageUsers && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Users}
|
icon={Users}
|
||||||
label="User Management"
|
label="User Management"
|
||||||
active={activePage === 'users'}
|
active={activePage === "users"}
|
||||||
onClick={() => onNavigate('users')}
|
onClick={() => onNavigate("users")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Work Allocation - SuperAdmin and Supervisor only */}
|
{/* Work Allocation - SuperAdmin and Supervisor only */}
|
||||||
{canManageAllocations && (
|
{canManageAllocations && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Briefcase}
|
icon={Briefcase}
|
||||||
label="Work Allocation"
|
label="Work Allocation"
|
||||||
active={activePage === 'allocation'}
|
active={activePage === "allocation"}
|
||||||
onClick={() => onNavigate('allocation')}
|
onClick={() => onNavigate("allocation")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attendance - SuperAdmin and Supervisor only */}
|
{/* Attendance - SuperAdmin and Supervisor only */}
|
||||||
{canManageAttendance && (
|
{canManageAttendance && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={CalendarCheck}
|
icon={CalendarCheck}
|
||||||
label="Attendance"
|
label="Attendance"
|
||||||
active={activePage === 'attendance'}
|
active={activePage === "attendance"}
|
||||||
onClick={() => onNavigate('attendance')}
|
onClick={() => onNavigate("attendance")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Contractor Rates - SuperAdmin and Supervisor only */}
|
{/* Contractor Rates - SuperAdmin and Supervisor only */}
|
||||||
{canManageRates && (
|
{canManageRates && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
label="Contractor Rates"
|
label="Contractor Rates"
|
||||||
active={activePage === 'rates'}
|
active={activePage === "rates"}
|
||||||
onClick={() => onNavigate('rates')}
|
onClick={() => onNavigate("rates")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Employee Swap - SuperAdmin only */}
|
{/* Employee Swap - SuperAdmin only */}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={ArrowRightLeft}
|
icon={ArrowRightLeft}
|
||||||
label="Employee Swap"
|
label="Employee Swap"
|
||||||
active={activePage === 'swaps'}
|
active={activePage === "swaps"}
|
||||||
onClick={() => onNavigate('swaps')}
|
onClick={() => onNavigate("swaps")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reports - SuperAdmin and Supervisor */}
|
{/* Reports - SuperAdmin and Supervisor */}
|
||||||
{canManageRates && (
|
{canManageRates && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={FileSpreadsheet}
|
icon={FileSpreadsheet}
|
||||||
label="Reports"
|
label="Reports"
|
||||||
active={activePage === 'reports'}
|
active={activePage === "reports"}
|
||||||
onClick={() => onNavigate('reports')}
|
onClick={() => onNavigate("reports")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Standard Rates - SuperAdmin and Supervisor */}
|
{/* Standard Rates - SuperAdmin and Supervisor */}
|
||||||
{canManageRates && (
|
{canManageRates && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Scale}
|
icon={Scale}
|
||||||
label="Standard Rates"
|
label="Standard Rates"
|
||||||
active={activePage === 'standard-rates'}
|
active={activePage === "standard-rates"}
|
||||||
onClick={() => onNavigate('standard-rates')}
|
onClick={() => onNavigate("standard-rates")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All Rates View - SuperAdmin only */}
|
{/* All Rates View - SuperAdmin only */}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Eye}
|
icon={Eye}
|
||||||
label="All Rates"
|
label="All Rates"
|
||||||
active={activePage === 'all-rates'}
|
active={activePage === "all-rates"}
|
||||||
onClick={() => onNavigate('all-rates')}
|
onClick={() => onNavigate("all-rates")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Activities Management - SuperAdmin and Supervisor */}
|
{/* Activities Management - SuperAdmin and Supervisor */}
|
||||||
{(isSuperAdmin || isSupervisor) && (
|
{(isSuperAdmin || isSupervisor) && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Layers}
|
icon={Layers}
|
||||||
label="Activities"
|
label="Activities"
|
||||||
active={activePage === 'activities'}
|
active={activePage === "activities"}
|
||||||
onClick={() => onNavigate('activities')}
|
onClick={() => onNavigate("activities")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Role indicator at bottom */}
|
{/* Role indicator at bottom */}
|
||||||
<div className="p-4 border-t border-gray-700">
|
<div className="p-4 border-t border-gray-700">
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Logged in as</div>
|
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">
|
||||||
<div className={`text-sm font-medium ${
|
Logged in as
|
||||||
isSuperAdmin ? 'text-purple-400' :
|
</div>
|
||||||
isSupervisor ? 'text-blue-400' :
|
<div
|
||||||
isContractor ? 'text-orange-400' :
|
className={`text-sm font-medium ${
|
||||||
isEmployee ? 'text-green-400' : 'text-gray-400'
|
isSuperAdmin
|
||||||
}`}>
|
? "text-purple-400"
|
||||||
{user?.role || 'Unknown'}
|
: isSupervisor
|
||||||
|
? "text-blue-400"
|
||||||
|
: isContractor
|
||||||
|
? "text-orange-400"
|
||||||
|
: isEmployee
|
||||||
|
? "text-green-400"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user?.role || "Unknown"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,40 +1,45 @@
|
|||||||
import React, { ReactNode, ButtonHTMLAttributes } from 'react';
|
import React, { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: "sm" | "md" | "lg";
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button: React.FC<ButtonProps> = ({
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
children,
|
children,
|
||||||
variant = 'primary',
|
variant = "primary",
|
||||||
size = 'md',
|
size = "md",
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
className = '',
|
className = "",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
|
const baseStyles =
|
||||||
|
"inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
|
||||||
|
|
||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
|
||||||
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500',
|
secondary:
|
||||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
"bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500",
|
||||||
ghost: 'bg-transparent border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
|
||||||
|
ghost:
|
||||||
|
"bg-transparent border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
sm: 'px-3 py-1.5 text-sm',
|
sm: "px-3 py-1.5 text-sm",
|
||||||
md: 'px-4 py-2 text-sm',
|
md: "px-4 py-2 text-sm",
|
||||||
lg: 'px-6 py-3 text-base',
|
lg: "px-6 py-3 text-base",
|
||||||
};
|
};
|
||||||
|
|
||||||
const widthStyle = fullWidth ? 'w-full' : '';
|
const widthStyle = fullWidth ? "w-full" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${widthStyle} ${className}`}
|
className={`${baseStyles} ${variantStyles[variant]} ${
|
||||||
|
sizeStyles[size]
|
||||||
|
} ${widthStyle} ${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
|
export const Card: React.FC<CardProps> = ({ children, className = "" }) => {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white rounded-lg shadow-sm ${className}`}>
|
<div className={`bg-white rounded-lg shadow-sm ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
@@ -19,9 +19,13 @@ interface CardHeaderProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardHeader: React.FC<CardHeaderProps> = ({ title, action, className = '' }) => {
|
export const CardHeader: React.FC<CardHeaderProps> = (
|
||||||
|
{ title, action, className = "" },
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<div className={`flex justify-between items-center p-6 border-b border-gray-200 ${className}`}>
|
<div
|
||||||
|
className={`flex justify-between items-center p-6 border-b border-gray-200 ${className}`}
|
||||||
|
>
|
||||||
<h2 className="text-xl font-semibold text-gray-800">{title}</h2>
|
<h2 className="text-xl font-semibold text-gray-800">{title}</h2>
|
||||||
{action && <div>{action}</div>}
|
{action && <div>{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -33,6 +37,8 @@ interface CardContentProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardContent: React.FC<CardContentProps> = ({ children, className = '' }) => {
|
export const CardContent: React.FC<CardContentProps> = (
|
||||||
|
{ children, className = "" },
|
||||||
|
) => {
|
||||||
return <div className={`p-6 ${className}`}>{children}</div>;
|
return <div className={`p-6 ${className}`}>{children}</div>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { InputHTMLAttributes, useState } from 'react';
|
import React, { InputHTMLAttributes, useState } from "react";
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -7,7 +7,9 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input: React.FC<InputProps> = ({ label, error, required, className = '', disabled, ...props }) => {
|
export const Input: React.FC<InputProps> = (
|
||||||
|
{ label, error, required, className = "", disabled, ...props },
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
@@ -17,8 +19,10 @@ export const Input: React.FC<InputProps> = ({ label, error, required, className
|
|||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
error ? 'border-red-500' : ''
|
error ? "border-red-500" : ""
|
||||||
} ${disabled ? 'bg-gray-100 text-gray-600 cursor-not-allowed' : ''} ${className}`}
|
} ${
|
||||||
|
disabled ? "bg-gray-100 text-gray-600 cursor-not-allowed" : ""
|
||||||
|
} ${className}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -27,13 +31,16 @@ export const Input: React.FC<InputProps> = ({ label, error, required, className
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
interface PasswordInputProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||||
label?: string;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PasswordInput: React.FC<PasswordInputProps> = ({ label, error, required, className = '', disabled, ...props }) => {
|
export const PasswordInput: React.FC<PasswordInputProps> = (
|
||||||
|
{ label, error, required, className = "", disabled, ...props },
|
||||||
|
) => {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,10 +52,12 @@ export const PasswordInput: React.FC<PasswordInputProps> = ({ label, error, requ
|
|||||||
)}
|
)}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? "text" : "password"}
|
||||||
className={`w-full px-4 py-2 pr-10 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
className={`w-full px-4 py-2 pr-10 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
error ? 'border-red-500' : ''
|
error ? "border-red-500" : ""
|
||||||
} ${disabled ? 'bg-gray-100 text-gray-600 cursor-not-allowed' : ''} ${className}`}
|
} ${
|
||||||
|
disabled ? "bg-gray-100 text-gray-600 cursor-not-allowed" : ""
|
||||||
|
} ${className}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -73,7 +82,9 @@ interface SelectProps extends InputHTMLAttributes<HTMLSelectElement> {
|
|||||||
options: { value: string; label: string }[];
|
options: { value: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Select: React.FC<SelectProps> = ({ label, error, required, options, className = '', disabled, ...props }) => {
|
export const Select: React.FC<SelectProps> = (
|
||||||
|
{ label, error, required, options, className = "", disabled, ...props },
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
@@ -83,8 +94,10 @@ export const Select: React.FC<SelectProps> = ({ label, error, required, options,
|
|||||||
)}
|
)}
|
||||||
<select
|
<select
|
||||||
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
error ? 'border-red-500' : ''
|
error ? "border-red-500" : ""
|
||||||
} ${disabled ? 'bg-gray-100 text-gray-600 cursor-not-allowed' : ''} ${className}`}
|
} ${
|
||||||
|
disabled ? "bg-gray-100 text-gray-600 cursor-not-allowed" : ""
|
||||||
|
} ${className}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -106,7 +119,9 @@ interface TextAreaProps extends InputHTMLAttributes<HTMLTextAreaElement> {
|
|||||||
rows?: number;
|
rows?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextArea: React.FC<TextAreaProps> = ({ label, error, required, rows = 3, className = '', ...props }) => {
|
export const TextArea: React.FC<TextAreaProps> = (
|
||||||
|
{ label, error, required, rows = 3, className = "", ...props },
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
@@ -117,7 +132,7 @@ export const TextArea: React.FC<TextAreaProps> = ({ label, error, required, rows
|
|||||||
<textarea
|
<textarea
|
||||||
rows={rows}
|
rows={rows}
|
||||||
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
error ? 'border-red-500' : ''
|
error ? "border-red-500" : ""
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
interface TableProps {
|
interface TableProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Table: React.FC<TableProps> = ({ children, className = '' }) => {
|
export const Table: React.FC<TableProps> = ({ children, className = "" }) => {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className={`w-full ${className}`}>{children}</table>
|
<table className={`w-full ${className}`}>{children}</table>
|
||||||
@@ -39,11 +39,15 @@ interface TableRowProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableRow: React.FC<TableRowProps> = ({ children, onClick, className = '' }) => {
|
export const TableRow: React.FC<TableRowProps> = (
|
||||||
|
{ children, onClick, className = "" },
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`border-b border-gray-100 hover:bg-gray-50 ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
className={`border-b border-gray-100 hover:bg-gray-50 ${
|
||||||
|
onClick ? "cursor-pointer" : ""
|
||||||
|
} ${className}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -55,9 +59,13 @@ interface TableHeadProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableHead: React.FC<TableHeadProps> = ({ children, className = '' }) => {
|
export const TableHead: React.FC<TableHeadProps> = (
|
||||||
|
{ children, className = "" },
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<th className={`text-left py-3 px-4 text-sm font-medium text-gray-600 ${className}`}>
|
<th
|
||||||
|
className={`text-left py-3 px-4 text-sm font-medium text-gray-600 ${className}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
@@ -68,6 +76,12 @@ interface TableCellProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableCell: React.FC<TableCellProps> = ({ children, className = '' }) => {
|
export const TableCell: React.FC<TableCellProps> = (
|
||||||
return <td className={`py-3 px-4 text-sm text-gray-700 ${className}`}>{children}</td>;
|
{ children, className = "" },
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<td className={`py-3 px-4 text-sm text-gray-700 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,76 +1,60 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { ReactNode, useState } from "react";
|
||||||
import { api } from '../services/api';
|
import { api } from "../services/api.ts";
|
||||||
import type { User } from '../types';
|
import type { User } from "../types.ts";
|
||||||
|
import { AuthContext } from "./authContext.ts";
|
||||||
|
|
||||||
interface AuthContextType {
|
// Re-export useAuth for convenience
|
||||||
user: User | null;
|
export { useAuth } from "./authContext.ts";
|
||||||
isAuthenticated: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
login: (username: string, password: string) => Promise<void>;
|
|
||||||
logout: () => void;
|
|
||||||
updateUser: (user: User) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
||||||
export const useAuth = () => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAuth must be used within AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
// Helper to get initial user from localStorage
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const getInitialUser = (): User | null => {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const token = localStorage.getItem("token");
|
||||||
|
const storedUser = localStorage.getItem("user");
|
||||||
|
|
||||||
useEffect(() => {
|
if (token && storedUser) {
|
||||||
// Check for existing session
|
try {
|
||||||
const token = localStorage.getItem('token');
|
return JSON.parse(storedUser);
|
||||||
const storedUser = localStorage.getItem('user');
|
} catch (error) {
|
||||||
|
console.error("Failed to parse stored user:", error);
|
||||||
if (token && storedUser) {
|
localStorage.removeItem("user");
|
||||||
try {
|
localStorage.removeItem("token");
|
||||||
const parsedUser = JSON.parse(storedUser);
|
|
||||||
setUser(parsedUser);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse stored user:', error);
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setIsLoading(false);
|
return null;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(getInitialUser);
|
||||||
|
const [isLoading] = useState(false);
|
||||||
|
|
||||||
const login = async (username: string, password: string) => {
|
const login = async (username: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.login(username, password);
|
const response = await api.login(username, password);
|
||||||
|
|
||||||
// Store token and user
|
// Store token and user
|
||||||
localStorage.setItem('token', response.token);
|
localStorage.setItem("token", response.token);
|
||||||
localStorage.setItem('user', JSON.stringify(response.user));
|
localStorage.setItem("user", JSON.stringify(response.user));
|
||||||
|
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', error);
|
console.error("Login failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem("user");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUser = (updatedUser: User) => {
|
const updateUser = (updatedUser: User) => {
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
localStorage.setItem("user", JSON.stringify(updatedUser));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
23
src/contexts/authContext.ts
Normal file
23
src/contexts/authContext.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { User } from "../types";
|
||||||
|
|
||||||
|
export interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from '../services/api';
|
import { api } from "../services/api.ts";
|
||||||
import { Activity } from '../types';
|
import { Activity } from "../types.ts";
|
||||||
|
|
||||||
export const useActivities = (subDepartmentId?: string | number) => {
|
export const useActivities = (subDepartmentId?: string | number) => {
|
||||||
const [activities, setActivities] = useState<Activity[]>([]);
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
@@ -18,7 +18,9 @@ export const useActivities = (subDepartmentId?: string | number) => {
|
|||||||
const data = await api.getActivities(params);
|
const data = await api.getActivities(params);
|
||||||
setActivities(data);
|
setActivities(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch activities');
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch activities",
|
||||||
|
);
|
||||||
setActivities([]);
|
setActivities([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -42,14 +44,18 @@ export const useActivitiesByDepartment = (departmentId?: string | number) => {
|
|||||||
setActivities([]);
|
setActivities([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.getActivities({ departmentId: Number(departmentId) });
|
const data = await api.getActivities({
|
||||||
|
departmentId: Number(departmentId),
|
||||||
|
});
|
||||||
setActivities(data);
|
setActivities(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch activities');
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch activities",
|
||||||
|
);
|
||||||
setActivities([]);
|
setActivities([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from '../services/api';
|
import { api } from "../services/api.ts";
|
||||||
import type { Department, SubDepartment } from '../types';
|
import type { Department, SubDepartment } from "../types.ts";
|
||||||
|
|
||||||
export const useDepartments = () => {
|
export const useDepartments = () => {
|
||||||
const [departments, setDepartments] = useState<Department[]>([]);
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
@@ -13,8 +13,8 @@ export const useDepartments = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await api.getDepartments();
|
const data = await api.getDepartments();
|
||||||
setDepartments(data);
|
setDepartments(data);
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to fetch departments');
|
setError(err.message || "Failed to fetch departments");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ export const useSubDepartments = (departmentId?: string) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchSubDepartments = async () => {
|
const fetchSubDepartments = useCallback(async () => {
|
||||||
if (!departmentId) {
|
if (!departmentId) {
|
||||||
setSubDepartments([]);
|
setSubDepartments([]);
|
||||||
return;
|
return;
|
||||||
@@ -48,16 +48,16 @@ export const useSubDepartments = (departmentId?: string) => {
|
|||||||
try {
|
try {
|
||||||
const data = await api.getSubDepartments(parseInt(departmentId));
|
const data = await api.getSubDepartments(parseInt(departmentId));
|
||||||
setSubDepartments(data);
|
setSubDepartments(data);
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to fetch subdepartments');
|
setError(err.message || "Failed to fetch subdepartments");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [departmentId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSubDepartments();
|
fetchSubDepartments();
|
||||||
}, [departmentId]);
|
}, [fetchSubDepartments]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subDepartments,
|
subDepartments,
|
||||||
@@ -65,4 +65,4 @@ export const useSubDepartments = (departmentId?: string) => {
|
|||||||
error,
|
error,
|
||||||
refresh: fetchSubDepartments,
|
refresh: fetchSubDepartments,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,56 +1,61 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from '../services/api';
|
import { api } from "../services/api";
|
||||||
import type { User } from '../types';
|
import type { User } from "../types";
|
||||||
|
|
||||||
export const useEmployees = (filters?: { role?: string; departmentId?: number }) => {
|
export const useEmployees = (
|
||||||
|
filters?: { role?: string; departmentId?: number },
|
||||||
|
) => {
|
||||||
const [employees, setEmployees] = useState<User[]>([]);
|
const [employees, setEmployees] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchEmployees = async () => {
|
const fetchEmployees = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.getUsers(filters);
|
const data = await api.getUsers(filters);
|
||||||
setEmployees(data);
|
setEmployees(data);
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to fetch employees');
|
setError(err.message || "Failed to fetch employees");
|
||||||
console.error('Failed to fetch employees:', err);
|
console.error("Failed to fetch employees:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEmployees();
|
fetchEmployees();
|
||||||
}, [JSON.stringify(filters)]);
|
}, [fetchEmployees]);
|
||||||
|
|
||||||
const createEmployee = async (data: any) => {
|
const createEmployee = async (data: Omit<User, "id">) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const newEmployee = await api.createUser(data);
|
const newEmployee = await api.createUser(data);
|
||||||
await fetchEmployees(); // Refresh list
|
await fetchEmployees(); // Refresh list
|
||||||
return newEmployee;
|
return newEmployee;
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to create employee');
|
setError(err.message || "Failed to create employee");
|
||||||
console.error('Failed to create employee:', err);
|
console.error("Failed to create employee:", err);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEmployee = async (id: number, data: any) => {
|
const updateEmployee = async (
|
||||||
|
id: number,
|
||||||
|
data: Partial<Omit<User, "id">>,
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateUser(id, data);
|
const updated = await api.updateUser(id, data);
|
||||||
await fetchEmployees(); // Refresh list
|
await fetchEmployees(); // Refresh list
|
||||||
return updated;
|
return updated;
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to update employee');
|
setError(err.message || "Failed to update employee");
|
||||||
console.error('Failed to update employee:', err);
|
console.error("Failed to update employee:", err);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -63,9 +68,9 @@ export const useEmployees = (filters?: { role?: string; departmentId?: number })
|
|||||||
try {
|
try {
|
||||||
await api.deleteUser(id);
|
await api.deleteUser(id);
|
||||||
await fetchEmployees(); // Refresh list
|
await fetchEmployees(); // Refresh list
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to delete employee');
|
setError(err.message || "Failed to delete employee");
|
||||||
console.error('Failed to delete employee:', err);
|
console.error("Failed to delete employee:", err);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,56 +1,66 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from '../services/api';
|
import { api } from "../services/api.ts";
|
||||||
import type { WorkAllocation } from '../types';
|
import type { WorkAllocation } from "../types.ts";
|
||||||
|
|
||||||
export const useWorkAllocations = (filters?: { employeeId?: number; status?: string; departmentId?: number }) => {
|
export const useWorkAllocations = (
|
||||||
|
filters?: { employeeId?: number; status?: string; departmentId?: number },
|
||||||
|
) => {
|
||||||
const [allocations, setAllocations] = useState<WorkAllocation[]>([]);
|
const [allocations, setAllocations] = useState<WorkAllocation[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchAllocations = async () => {
|
const fetchAllocations = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.getWorkAllocations(filters);
|
const data = await api.getWorkAllocations(filters);
|
||||||
setAllocations(data);
|
setAllocations(data);
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to fetch work allocations');
|
setError(err.message || "Failed to fetch work allocations");
|
||||||
console.error('Failed to fetch work allocations:', err);
|
console.error("Failed to fetch work allocations:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAllocations();
|
fetchAllocations();
|
||||||
}, [JSON.stringify(filters)]);
|
}, [fetchAllocations]);
|
||||||
|
|
||||||
const createAllocation = async (data: any) => {
|
const createAllocation = async (data: Omit<WorkAllocation, "id">) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const newAllocation = await api.createWorkAllocation(data);
|
const newAllocation = await api.createWorkAllocation(data);
|
||||||
await fetchAllocations(); // Refresh list
|
await fetchAllocations(); // Refresh list
|
||||||
return newAllocation;
|
return newAllocation;
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to create work allocation');
|
setError(err.message || "Failed to create work allocation");
|
||||||
console.error('Failed to create work allocation:', err);
|
console.error("Failed to create work allocation:", err);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAllocation = async (id: number, status: string, completionDate?: string) => {
|
const updateAllocation = async (
|
||||||
|
id: number,
|
||||||
|
status: string,
|
||||||
|
completionDate?: string,
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateWorkAllocationStatus(id, status, completionDate);
|
const updated = await api.updateWorkAllocationStatus(
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
completionDate,
|
||||||
|
);
|
||||||
await fetchAllocations(); // Refresh list
|
await fetchAllocations(); // Refresh list
|
||||||
return updated;
|
return updated;
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to update work allocation');
|
setError(err.message || "Failed to update work allocation");
|
||||||
console.error('Failed to update work allocation:', err);
|
console.error("Failed to update work allocation:", err);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -63,9 +73,9 @@ export const useWorkAllocations = (filters?: { employeeId?: number; status?: str
|
|||||||
try {
|
try {
|
||||||
await api.deleteWorkAllocation(id);
|
await api.deleteWorkAllocation(id);
|
||||||
await fetchAllocations(); // Refresh list
|
await fetchAllocations(); // Refresh list
|
||||||
} catch (err: any) {
|
} catch (err: never) {
|
||||||
setError(err.message || 'Failed to delete work allocation');
|
setError(err.message || "Failed to delete work allocation");
|
||||||
console.error('Failed to delete work allocation:', err);
|
console.error("Failed to delete work allocation:", err);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
10
src/main.tsx
10
src/main.tsx
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css';
|
import "./index.css";
|
||||||
import App from './App';
|
import App from "./App";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|||||||
@@ -1,43 +1,62 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { Plus, RefreshCw, Trash2, Layers, Activity as ActivityIcon } from 'lucide-react';
|
import {
|
||||||
import { Card, CardHeader, CardContent } from '../components/ui/Card';
|
Activity as ActivityIcon,
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
Layers,
|
||||||
import { Button } from '../components/ui/Button';
|
Plus,
|
||||||
import { Input, Select } from '../components/ui/Input';
|
RefreshCw,
|
||||||
import { useDepartments, useSubDepartments } from '../hooks/useDepartments';
|
Trash2,
|
||||||
import { useActivitiesByDepartment } from '../hooks/useActivities';
|
} from "lucide-react";
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { Card, CardContent, CardHeader } from "../components/ui/Card.tsx";
|
||||||
import { api } from '../services/api';
|
import {
|
||||||
import { SubDepartment, Activity } from '../types';
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../components/ui/Table.tsx";
|
||||||
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
|
import { Input, Select } from "../components/ui/Input.tsx";
|
||||||
|
import { useDepartments, useSubDepartments } from "../hooks/useDepartments.ts";
|
||||||
|
import { useActivitiesByDepartment } from "../hooks/useActivities.ts";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
import { api } from "../services/api.ts";
|
||||||
|
import { Activity, SubDepartment } from "../types.ts";
|
||||||
|
|
||||||
export const ActivitiesPage: React.FC = () => {
|
export const ActivitiesPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'subDepartments' | 'activities'>('subDepartments');
|
const [activeTab, setActiveTab] = useState<"subDepartments" | "activities">(
|
||||||
|
"subDepartments",
|
||||||
|
);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { departments } = useDepartments();
|
const { departments } = useDepartments();
|
||||||
|
|
||||||
// Role-based access
|
// Role-based access
|
||||||
const isSupervisor = user?.role === 'Supervisor';
|
const isSupervisor = user?.role === "Supervisor";
|
||||||
const isSuperAdmin = user?.role === 'SuperAdmin';
|
const isSuperAdmin = user?.role === "SuperAdmin";
|
||||||
const canAccess = isSupervisor || isSuperAdmin;
|
const canAccess = isSupervisor || isSuperAdmin;
|
||||||
|
|
||||||
// Department selection - supervisors are locked to their department
|
// Department selection - supervisors are locked to their department
|
||||||
const [selectedDeptId, setSelectedDeptId] = useState<string>('');
|
const [selectedDeptId, setSelectedDeptId] = useState<string>("");
|
||||||
|
|
||||||
// Get sub-departments and activities for selected department
|
// Get sub-departments and activities for selected department
|
||||||
const { subDepartments, refresh: refreshSubDepts } = useSubDepartments(selectedDeptId);
|
const { subDepartments, refresh: refreshSubDepts } = useSubDepartments(
|
||||||
const { activities, refresh: refreshActivities } = useActivitiesByDepartment(selectedDeptId);
|
selectedDeptId,
|
||||||
|
);
|
||||||
|
const { activities, refresh: refreshActivities } = useActivitiesByDepartment(
|
||||||
|
selectedDeptId,
|
||||||
|
);
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [subDeptForm, setSubDeptForm] = useState({ name: '' });
|
const [subDeptForm, setSubDeptForm] = useState({ name: "" });
|
||||||
const [activityForm, setActivityForm] = useState({
|
const [activityForm, setActivityForm] = useState({
|
||||||
subDepartmentId: '',
|
subDepartmentId: "",
|
||||||
name: '',
|
name: "",
|
||||||
unitOfMeasurement: 'Per Bag' as 'Per Bag' | 'Fixed Rate-Per Person'
|
unitOfMeasurement: "Per Bag" as "Per Bag" | "Fixed Rate-Per Person",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
// Auto-select department for supervisors
|
// Auto-select department for supervisors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,8 +69,8 @@ export const ActivitiesPage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (success || error) {
|
if (success || error) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setSuccess('');
|
setSuccess("");
|
||||||
setError('');
|
setError("");
|
||||||
}, 3000);
|
}, 3000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
@@ -59,44 +78,52 @@ export const ActivitiesPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleCreateSubDepartment = async () => {
|
const handleCreateSubDepartment = async () => {
|
||||||
if (!subDeptForm.name.trim()) {
|
if (!subDeptForm.name.trim()) {
|
||||||
setError('Sub-department name is required');
|
setError("Sub-department name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedDeptId) {
|
if (!selectedDeptId) {
|
||||||
setError('Please select a department first');
|
setError("Please select a department first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
await api.createSubDepartment({
|
await api.createSubDepartment({
|
||||||
department_id: parseInt(selectedDeptId),
|
department_id: parseInt(selectedDeptId),
|
||||||
name: subDeptForm.name.trim()
|
name: subDeptForm.name.trim(),
|
||||||
});
|
});
|
||||||
setSuccess('Sub-department created successfully');
|
setSuccess("Sub-department created successfully");
|
||||||
setSubDeptForm({ name: '' });
|
setSubDeptForm({ name: "" });
|
||||||
refreshSubDepts();
|
refreshSubDepts();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create sub-department');
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to create sub-department",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSubDepartment = async (id: number) => {
|
const handleDeleteSubDepartment = async (id: number) => {
|
||||||
if (!confirm('Are you sure you want to delete this sub-department? This will also delete all associated activities.')) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Are you sure you want to delete this sub-department? This will also delete all associated activities.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.deleteSubDepartment(id);
|
await api.deleteSubDepartment(id);
|
||||||
setSuccess('Sub-department deleted successfully');
|
setSuccess("Sub-department deleted successfully");
|
||||||
refreshSubDepts();
|
refreshSubDepts();
|
||||||
refreshActivities();
|
refreshActivities();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete sub-department');
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to delete sub-department",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -104,44 +131,52 @@ export const ActivitiesPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleCreateActivity = async () => {
|
const handleCreateActivity = async () => {
|
||||||
if (!activityForm.name.trim()) {
|
if (!activityForm.name.trim()) {
|
||||||
setError('Activity name is required');
|
setError("Activity name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!activityForm.subDepartmentId) {
|
if (!activityForm.subDepartmentId) {
|
||||||
setError('Please select a sub-department');
|
setError("Please select a sub-department");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
await api.createActivity({
|
await api.createActivity({
|
||||||
sub_department_id: parseInt(activityForm.subDepartmentId),
|
sub_department_id: parseInt(activityForm.subDepartmentId),
|
||||||
name: activityForm.name.trim(),
|
name: activityForm.name.trim(),
|
||||||
unit_of_measurement: activityForm.unitOfMeasurement
|
unit_of_measurement: activityForm.unitOfMeasurement,
|
||||||
|
});
|
||||||
|
setSuccess("Activity created successfully");
|
||||||
|
setActivityForm({
|
||||||
|
subDepartmentId: "",
|
||||||
|
name: "",
|
||||||
|
unitOfMeasurement: "Per Bag",
|
||||||
});
|
});
|
||||||
setSuccess('Activity created successfully');
|
|
||||||
setActivityForm({ subDepartmentId: '', name: '', unitOfMeasurement: 'Per Bag' });
|
|
||||||
refreshActivities();
|
refreshActivities();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create activity');
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to create activity",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteActivity = async (id: number) => {
|
const handleDeleteActivity = async (id: number) => {
|
||||||
if (!confirm('Are you sure you want to delete this activity?')) {
|
if (!confirm("Are you sure you want to delete this activity?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.deleteActivity(id);
|
await api.deleteActivity(id);
|
||||||
setSuccess('Activity deleted successfully');
|
setSuccess("Activity deleted successfully");
|
||||||
refreshActivities();
|
refreshActivities();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete activity');
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to delete activity",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -152,14 +187,17 @@ export const ActivitiesPage: React.FC = () => {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-red-600">You do not have permission to access this page.</p>
|
<p className="text-red-600">
|
||||||
|
You do not have permission to access this page.
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDeptName = departments.find(d => d.id === parseInt(selectedDeptId))?.name || '';
|
const selectedDeptName =
|
||||||
|
departments.find((d) => d.id === parseInt(selectedDeptId))?.name || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -168,15 +206,22 @@ export const ActivitiesPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Layers className="h-6 w-6 text-blue-600" />
|
<Layers className="h-6 w-6 text-blue-600" />
|
||||||
<h2 className="text-xl font-semibold text-gray-800">Manage Activities & Sub-Departments</h2>
|
<h2 className="text-xl font-semibold text-gray-800">
|
||||||
|
Manage Activities & Sub-Departments
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { refreshSubDepts(); refreshActivities(); }}
|
onClick={() => {
|
||||||
|
refreshSubDepts();
|
||||||
|
refreshActivities();
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,23 +230,33 @@ export const ActivitiesPage: React.FC = () => {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Department Selection */}
|
{/* Department Selection */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
{isSupervisor ? (
|
{isSupervisor
|
||||||
<div>
|
? (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
|
<div>
|
||||||
<Input value={selectedDeptName || 'Loading...'} disabled />
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<p className="text-xs text-gray-500 mt-1">As a supervisor, you can only manage your department's activities.</p>
|
Department
|
||||||
</div>
|
</label>
|
||||||
) : (
|
<Input value={selectedDeptName || "Loading..."} disabled />
|
||||||
<Select
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
label="Select Department"
|
As a supervisor, you can only manage your department's
|
||||||
value={selectedDeptId}
|
activities.
|
||||||
onChange={(e) => setSelectedDeptId(e.target.value)}
|
</p>
|
||||||
options={[
|
</div>
|
||||||
{ value: '', label: 'Select a Department' },
|
)
|
||||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
: (
|
||||||
]}
|
<Select
|
||||||
/>
|
label="Select Department"
|
||||||
)}
|
value={selectedDeptId}
|
||||||
|
onChange={(e) => setSelectedDeptId(e.target.value)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Select a Department" },
|
||||||
|
...departments.map((d) => ({
|
||||||
|
value: String(d.id),
|
||||||
|
label: d.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
@@ -220,22 +275,22 @@ export const ActivitiesPage: React.FC = () => {
|
|||||||
<div className="border-b border-gray-200 mb-6">
|
<div className="border-b border-gray-200 mb-6">
|
||||||
<div className="flex space-x-8">
|
<div className="flex space-x-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('subDepartments')}
|
onClick={() => setActiveTab("subDepartments")}
|
||||||
className={`py-3 px-1 border-b-2 font-medium text-sm ${
|
className={`py-3 px-1 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'subDepartments'
|
activeTab === "subDepartments"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Layers className="h-4 w-4 inline mr-2" />
|
<Layers className="h-4 w-4 inline mr-2" />
|
||||||
Sub-Departments ({subDepartments.length})
|
Sub-Departments ({subDepartments.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('activities')}
|
onClick={() => setActiveTab("activities")}
|
||||||
className={`py-3 px-1 border-b-2 font-medium text-sm ${
|
className={`py-3 px-1 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'activities'
|
activeTab === "activities"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ActivityIcon className="h-4 w-4 inline mr-2" />
|
<ActivityIcon className="h-4 w-4 inline mr-2" />
|
||||||
@@ -244,165 +299,226 @@ export const ActivitiesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedDeptId ? (
|
{!selectedDeptId
|
||||||
<p className="text-gray-500 text-center py-8">Please select a department to manage sub-departments and activities.</p>
|
? (
|
||||||
) : (
|
<p className="text-gray-500 text-center py-8">
|
||||||
<>
|
Please select a department to manage sub-departments and
|
||||||
{/* Sub-Departments Tab */}
|
activities.
|
||||||
{activeTab === 'subDepartments' && (
|
</p>
|
||||||
<div className="space-y-6">
|
)
|
||||||
{/* Create Sub-Department Form */}
|
: (
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
<>
|
||||||
<h3 className="text-md font-semibold text-gray-700 mb-4">Add New Sub-Department</h3>
|
{/* Sub-Departments Tab */}
|
||||||
<div className="flex gap-4 items-end">
|
{activeTab === "subDepartments" && (
|
||||||
<div className="flex-1">
|
<div className="space-y-6">
|
||||||
<Input
|
{/* Create Sub-Department Form */}
|
||||||
label="Sub-Department Name"
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
value={subDeptForm.name}
|
<h3 className="text-md font-semibold text-gray-700 mb-4">
|
||||||
onChange={(e) => setSubDeptForm({ name: e.target.value })}
|
Add New Sub-Department
|
||||||
placeholder="e.g., Loading/Unloading, Destoner, Tank"
|
</h3>
|
||||||
/>
|
<div className="flex gap-4 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label="Sub-Department Name"
|
||||||
|
value={subDeptForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSubDeptForm({ name: e.target.value })}
|
||||||
|
placeholder="e.g., Loading/Unloading, Destoner, Tank"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateSubDepartment}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Sub-Department
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreateSubDepartment} disabled={loading}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Sub-Department
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sub-Departments List */}
|
{/* Sub-Departments List */}
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableHead>Sub-Department Name</TableHead>
|
<TableHead>Sub-Department Name</TableHead>
|
||||||
<TableHead>Activities Count</TableHead>
|
<TableHead>Activities Count</TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<TableHead>Created At</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{subDepartments.length === 0 ? (
|
{subDepartments.length === 0
|
||||||
<TableRow>
|
? (
|
||||||
<TableCell colSpan={4} className="text-center text-gray-500 py-8">
|
<TableRow>
|
||||||
No sub-departments found. Create one above.
|
<TableCell
|
||||||
</TableCell>
|
colSpan={4}
|
||||||
</TableRow>
|
className="text-center text-gray-500 py-8"
|
||||||
) : (
|
>
|
||||||
subDepartments.map((subDept: SubDepartment) => {
|
No sub-departments found. Create one above.
|
||||||
const activityCount = activities.filter(a => a.sub_department_id === subDept.id).length;
|
|
||||||
return (
|
|
||||||
<TableRow key={subDept.id}>
|
|
||||||
<TableCell className="font-medium">{subDept.name}</TableCell>
|
|
||||||
<TableCell>{activityCount}</TableCell>
|
|
||||||
<TableCell>{new Date(subDept.created_at).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteSubDepartment(subDept.id)}
|
|
||||||
className="text-red-600 hover:text-red-800"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
)
|
||||||
})
|
: (
|
||||||
)}
|
subDepartments.map((subDept: SubDepartment) => {
|
||||||
</TableBody>
|
const activityCount = activities.filter((a) =>
|
||||||
</Table>
|
a.sub_department_id === subDept.id
|
||||||
</div>
|
).length;
|
||||||
)}
|
return (
|
||||||
|
<TableRow key={subDept.id}>
|
||||||
{/* Activities Tab */}
|
<TableCell className="font-medium">
|
||||||
{activeTab === 'activities' && (
|
{subDept.name}
|
||||||
<div className="space-y-6">
|
</TableCell>
|
||||||
{/* Create Activity Form */}
|
<TableCell>{activityCount}</TableCell>
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
<TableCell>
|
||||||
<h3 className="text-md font-semibold text-gray-700 mb-4">Add New Activity</h3>
|
{new Date(subDept.created_at)
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
.toLocaleDateString()}
|
||||||
<Select
|
</TableCell>
|
||||||
label="Sub-Department"
|
<TableCell className="text-right">
|
||||||
value={activityForm.subDepartmentId}
|
<Button
|
||||||
onChange={(e) => setActivityForm(prev => ({ ...prev, subDepartmentId: e.target.value }))}
|
variant="ghost"
|
||||||
options={[
|
size="sm"
|
||||||
{ value: '', label: 'Select Sub-Department' },
|
onClick={() =>
|
||||||
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
|
handleDeleteSubDepartment(subDept.id)}
|
||||||
]}
|
className="text-red-600 hover:text-red-800"
|
||||||
/>
|
>
|
||||||
<Input
|
<Trash2 className="h-4 w-4" />
|
||||||
label="Activity Name"
|
</Button>
|
||||||
value={activityForm.name}
|
</TableCell>
|
||||||
onChange={(e) => setActivityForm(prev => ({ ...prev, name: e.target.value }))}
|
</TableRow>
|
||||||
placeholder="e.g., Mufali Aavak Katai"
|
);
|
||||||
/>
|
})
|
||||||
<Select
|
)}
|
||||||
label="Unit of Measurement"
|
</TableBody>
|
||||||
value={activityForm.unitOfMeasurement}
|
</Table>
|
||||||
onChange={(e) => setActivityForm(prev => ({
|
|
||||||
...prev,
|
|
||||||
unitOfMeasurement: e.target.value as 'Per Bag' | 'Fixed Rate-Per Person'
|
|
||||||
}))}
|
|
||||||
options={[
|
|
||||||
{ value: 'Per Bag', label: 'Per Bag' },
|
|
||||||
{ value: 'Fixed Rate-Per Person', label: 'Fixed Rate-Per Person' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Button onClick={handleCreateActivity} disabled={loading}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Activity
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Activities List */}
|
{/* Activities Tab */}
|
||||||
<Table>
|
{activeTab === "activities" && (
|
||||||
<TableHeader>
|
<div className="space-y-6">
|
||||||
<TableHead>Activity Name</TableHead>
|
{/* Create Activity Form */}
|
||||||
<TableHead>Sub-Department</TableHead>
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
<TableHead>Unit of Measurement</TableHead>
|
<h3 className="text-md font-semibold text-gray-700 mb-4">
|
||||||
<TableHead>Created At</TableHead>
|
Add New Activity
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
</h3>
|
||||||
</TableHeader>
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||||
<TableBody>
|
<Select
|
||||||
{activities.length === 0 ? (
|
label="Sub-Department"
|
||||||
<TableRow>
|
value={activityForm.subDepartmentId}
|
||||||
<TableCell colSpan={5} className="text-center text-gray-500 py-8">
|
onChange={(e) =>
|
||||||
No activities found. Create one above.
|
setActivityForm((prev) => ({
|
||||||
</TableCell>
|
...prev,
|
||||||
</TableRow>
|
subDepartmentId: e.target.value,
|
||||||
) : (
|
}))}
|
||||||
activities.map((activity: Activity) => (
|
options={[
|
||||||
<TableRow key={activity.id}>
|
{ value: "", label: "Select Sub-Department" },
|
||||||
<TableCell className="font-medium">{activity.name}</TableCell>
|
...subDepartments.map((s) => ({
|
||||||
<TableCell>{activity.sub_department_name}</TableCell>
|
value: String(s.id),
|
||||||
<TableCell>
|
label: s.name,
|
||||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
})),
|
||||||
activity.unit_of_measurement === 'Per Bag'
|
]}
|
||||||
? 'bg-blue-100 text-blue-800'
|
/>
|
||||||
: 'bg-green-100 text-green-800'
|
<Input
|
||||||
}`}>
|
label="Activity Name"
|
||||||
{activity.unit_of_measurement}
|
value={activityForm.name}
|
||||||
</span>
|
onChange={(e) =>
|
||||||
</TableCell>
|
setActivityForm((prev) => ({
|
||||||
<TableCell>{new Date(activity.created_at).toLocaleDateString()}</TableCell>
|
...prev,
|
||||||
<TableCell className="text-right">
|
name: e.target.value,
|
||||||
<Button
|
}))}
|
||||||
variant="ghost"
|
placeholder="e.g., Mufali Aavak Katai"
|
||||||
size="sm"
|
/>
|
||||||
onClick={() => handleDeleteActivity(activity.id)}
|
<Select
|
||||||
className="text-red-600 hover:text-red-800"
|
label="Unit of Measurement"
|
||||||
|
value={activityForm.unitOfMeasurement}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActivityForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
unitOfMeasurement: e.target.value as
|
||||||
|
| "Per Bag"
|
||||||
|
| "Fixed Rate-Per Person",
|
||||||
|
}))}
|
||||||
|
options={[
|
||||||
|
{ value: "Per Bag", label: "Per Bag" },
|
||||||
|
{
|
||||||
|
value: "Fixed Rate-Per Person",
|
||||||
|
label: "Fixed Rate-Per Person",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateActivity}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Activity
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activities List */}
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableHead>Activity Name</TableHead>
|
||||||
|
<TableHead>Sub-Department</TableHead>
|
||||||
|
<TableHead>Unit of Measurement</TableHead>
|
||||||
|
<TableHead>Created At</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{activities.length === 0
|
||||||
|
? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={5}
|
||||||
|
className="text-center text-gray-500 py-8"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
No activities found. Create one above.
|
||||||
</Button>
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
</TableRow>
|
)
|
||||||
))
|
: (
|
||||||
)}
|
activities.map((activity: Activity) => (
|
||||||
</TableBody>
|
<TableRow key={activity.id}>
|
||||||
</Table>
|
<TableCell className="font-medium">
|
||||||
</div>
|
{activity.name}
|
||||||
)}
|
</TableCell>
|
||||||
</>
|
<TableCell>
|
||||||
)}
|
{activity.sub_department_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs ${
|
||||||
|
activity.unit_of_measurement === "Per Bag"
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-green-100 text-green-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activity.unit_of_measurement}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(activity.created_at)
|
||||||
|
.toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteActivity(activity.id)}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,54 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshCw, Search, Filter, Eye, Calendar } from 'lucide-react';
|
import { Calendar, Eye, Filter, RefreshCw, Search } from "lucide-react";
|
||||||
import { Card, CardContent } from '../components/ui/Card';
|
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
import {
|
||||||
import { Button } from '../components/ui/Button';
|
Table,
|
||||||
import { Input, Select } from '../components/ui/Input';
|
TableBody,
|
||||||
import { api } from '../services/api';
|
TableCell,
|
||||||
import { useDepartments } from '../hooks/useDepartments';
|
TableHead,
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../components/ui/Table.tsx";
|
||||||
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
|
import { Input, Select } from "../components/ui/Input.tsx";
|
||||||
|
import { api } from "../services/api.ts";
|
||||||
|
import { useDepartments } from "../hooks/useDepartments.ts";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
|
||||||
export const AllRatesPage: React.FC = () => {
|
export const AllRatesPage: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { departments } = useDepartments();
|
const { departments } = useDepartments();
|
||||||
const [allRates, setAllRates] = useState<any[]>([]);
|
const [allRates, setAllRates] = useState<any[]>([]);
|
||||||
const [summary, setSummary] = useState<{ totalContractorRates: number; totalStandardRates: number; totalRates: number } | null>(null);
|
const [summary, setSummary] = useState<
|
||||||
|
{
|
||||||
|
totalContractorRates: number;
|
||||||
|
totalStandardRates: number;
|
||||||
|
totalRates: number;
|
||||||
|
} | null
|
||||||
|
>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
departmentId: '',
|
departmentId: "",
|
||||||
startDate: '',
|
startDate: "",
|
||||||
endDate: '',
|
endDate: "",
|
||||||
rateType: '', // 'contractor' | 'standard' | ''
|
rateType: "", // 'contractor' | 'standard' | ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSuperAdmin = user?.role === 'SuperAdmin';
|
const isSuperAdmin = user?.role === "SuperAdmin";
|
||||||
|
|
||||||
// Fetch all rates
|
// Fetch all rates
|
||||||
const fetchAllRates = async () => {
|
const fetchAllRates = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (filters.departmentId) params.departmentId = parseInt(filters.departmentId);
|
if (filters.departmentId) {
|
||||||
|
params.departmentId = parseInt(filters.departmentId);
|
||||||
|
}
|
||||||
if (filters.startDate) params.startDate = filters.startDate;
|
if (filters.startDate) params.startDate = filters.startDate;
|
||||||
if (filters.endDate) params.endDate = filters.endDate;
|
if (filters.endDate) params.endDate = filters.endDate;
|
||||||
|
|
||||||
@@ -41,7 +56,7 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
setAllRates(data.allRates);
|
setAllRates(data.allRates);
|
||||||
setSummary(data.summary);
|
setSummary(data.summary);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch rates');
|
setError(err.message || "Failed to fetch rates");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -53,9 +68,11 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isSuperAdmin]);
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleFilterChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFilters(prev => ({ ...prev, [name]: value }));
|
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
@@ -64,10 +81,10 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setFilters({
|
setFilters({
|
||||||
departmentId: '',
|
departmentId: "",
|
||||||
startDate: '',
|
startDate: "",
|
||||||
endDate: '',
|
endDate: "",
|
||||||
rateType: '',
|
rateType: "",
|
||||||
});
|
});
|
||||||
setTimeout(fetchAllRates, 0);
|
setTimeout(fetchAllRates, 0);
|
||||||
};
|
};
|
||||||
@@ -75,16 +92,16 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
// Filter rates based on search and rate type
|
// Filter rates based on search and rate type
|
||||||
const filteredRates = useMemo(() => {
|
const filteredRates = useMemo(() => {
|
||||||
let rates = allRates;
|
let rates = allRates;
|
||||||
|
|
||||||
// Filter by rate type
|
// Filter by rate type
|
||||||
if (filters.rateType) {
|
if (filters.rateType) {
|
||||||
rates = rates.filter(r => r.rate_type === filters.rateType);
|
rates = rates.filter((r) => r.rate_type === filters.rateType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
rates = rates.filter(r =>
|
rates = rates.filter((r) =>
|
||||||
r.contractor_name?.toLowerCase().includes(query) ||
|
r.contractor_name?.toLowerCase().includes(query) ||
|
||||||
r.sub_department_name?.toLowerCase().includes(query) ||
|
r.sub_department_name?.toLowerCase().includes(query) ||
|
||||||
r.department_name?.toLowerCase().includes(query) ||
|
r.department_name?.toLowerCase().includes(query) ||
|
||||||
@@ -92,7 +109,7 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
r.created_by_name?.toLowerCase().includes(query)
|
r.created_by_name?.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rates;
|
return rates;
|
||||||
}, [allRates, searchQuery, filters.rateType]);
|
}, [allRates, searchQuery, filters.rateType]);
|
||||||
|
|
||||||
@@ -104,7 +121,9 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Eye size={48} className="mx-auto text-gray-400 mb-4" />
|
<Eye size={48} className="mx-auto text-gray-400 mb-4" />
|
||||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">Access Restricted</h2>
|
<h2 className="text-xl font-semibold text-gray-700 mb-2">
|
||||||
|
Access Restricted
|
||||||
|
</h2>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
This page is only accessible to Super Admin accounts.
|
This page is only accessible to Super Admin accounts.
|
||||||
</p>
|
</p>
|
||||||
@@ -123,8 +142,12 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Eye className="text-purple-600" size={24} />
|
<Eye className="text-purple-600" size={24} />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-gray-800">All Rates Overview</h2>
|
<h2 className="text-xl font-semibold text-gray-800">
|
||||||
<p className="text-sm text-gray-500">View all contractor and standard rates across all departments</p>
|
All Rates Overview
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
View all contractor and standard rates across all departments
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,8 +167,11 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
value={filters.departmentId}
|
value={filters.departmentId}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'All Departments' },
|
{ value: "", label: "All Departments" },
|
||||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
...departments.map((d) => ({
|
||||||
|
value: String(d.id),
|
||||||
|
label: d.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -154,13 +180,15 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
value={filters.rateType}
|
value={filters.rateType}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'All Types' },
|
{ value: "", label: "All Types" },
|
||||||
{ value: 'contractor', label: 'Contractor Rates' },
|
{ value: "contractor", label: "Contractor Rates" },
|
||||||
{ value: 'standard', label: 'Standard Rates' },
|
{ value: "standard", label: "Standard Rates" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
name="startDate"
|
name="startDate"
|
||||||
@@ -169,7 +197,9 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
name="endDate"
|
name="endDate"
|
||||||
@@ -192,16 +222,28 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
{summary && (
|
{summary && (
|
||||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<div className="text-sm text-blue-600 font-medium">Total Rates</div>
|
<div className="text-sm text-blue-600 font-medium">
|
||||||
<div className="text-2xl font-bold text-blue-800">{summary.totalRates}</div>
|
Total Rates
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-800">
|
||||||
|
{summary.totalRates}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||||
<div className="text-sm text-orange-600 font-medium">Contractor Rates</div>
|
<div className="text-sm text-orange-600 font-medium">
|
||||||
<div className="text-2xl font-bold text-orange-800">{summary.totalContractorRates}</div>
|
Contractor Rates
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-orange-800">
|
||||||
|
{summary.totalContractorRates}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="text-sm text-green-600 font-medium">Standard Rates</div>
|
<div className="text-sm text-green-600 font-medium">
|
||||||
<div className="text-2xl font-bold text-green-800">{summary.totalStandardRates}</div>
|
Standard Rates
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-800">
|
||||||
|
{summary.totalStandardRates}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -209,7 +251,10 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
{/* Search and Refresh */}
|
{/* Search and Refresh */}
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by contractor, department, activity..."
|
placeholder="Search by contractor, department, activity..."
|
||||||
@@ -232,69 +277,80 @@ export const AllRatesPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{loading ? (
|
{loading
|
||||||
<div className="text-center py-8">Loading all rates...</div>
|
? <div className="text-center py-8">Loading all rates...</div>
|
||||||
) : filteredRates.length > 0 ? (
|
: filteredRates.length > 0
|
||||||
<div className="overflow-x-auto">
|
? (
|
||||||
<Table>
|
<div className="overflow-x-auto">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableHead>Type</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Contractor</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead>Department</TableHead>
|
<TableHead>Contractor</TableHead>
|
||||||
<TableHead>Sub-Department</TableHead>
|
<TableHead>Department</TableHead>
|
||||||
<TableHead>Activity</TableHead>
|
<TableHead>Sub-Department</TableHead>
|
||||||
<TableHead>Rate (₹)</TableHead>
|
<TableHead>Activity</TableHead>
|
||||||
<TableHead>Effective Date</TableHead>
|
<TableHead>Rate (₹)</TableHead>
|
||||||
<TableHead>Created By</TableHead>
|
<TableHead>Effective Date</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Created By</TableHead>
|
||||||
<TableBody>
|
</TableHeader>
|
||||||
{filteredRates.map((rate, idx) => (
|
<TableBody>
|
||||||
<TableRow key={`${rate.rate_type}-${rate.id}-${idx}`}>
|
{filteredRates.map((rate, idx) => (
|
||||||
<TableCell>
|
<TableRow key={`${rate.rate_type}-${rate.id}-${idx}`}>
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
<TableCell>
|
||||||
rate.rate_type === 'contractor'
|
<span
|
||||||
? 'bg-orange-100 text-orange-700'
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
: 'bg-green-100 text-green-700'
|
rate.rate_type === "contractor"
|
||||||
}`}>
|
? "bg-orange-100 text-orange-700"
|
||||||
{rate.rate_type === 'contractor' ? 'Contractor' : 'Standard'}
|
: "bg-green-100 text-green-700"
|
||||||
</span>
|
}`}
|
||||||
</TableCell>
|
>
|
||||||
<TableCell className="font-medium">
|
{rate.rate_type === "contractor"
|
||||||
{rate.contractor_name || '-'}
|
? "Contractor"
|
||||||
</TableCell>
|
: "Standard"}
|
||||||
<TableCell>{rate.department_name || '-'}</TableCell>
|
</span>
|
||||||
<TableCell>{rate.sub_department_name || '-'}</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="font-medium">
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
{rate.contractor_name || "-"}
|
||||||
rate.activity === 'Loading' || rate.activity === 'Unloading'
|
</TableCell>
|
||||||
? 'bg-purple-100 text-purple-700'
|
<TableCell>{rate.department_name || "-"}</TableCell>
|
||||||
: 'bg-gray-100 text-gray-700'
|
<TableCell>{rate.sub_department_name || "-"}</TableCell>
|
||||||
}`}>
|
<TableCell>
|
||||||
{rate.activity || 'Standard'}
|
<span
|
||||||
</span>
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
</TableCell>
|
rate.activity === "Loading" ||
|
||||||
<TableCell>
|
rate.activity === "Unloading"
|
||||||
<span className="text-green-600 font-semibold">₹{rate.rate}</span>
|
? "bg-purple-100 text-purple-700"
|
||||||
</TableCell>
|
: "bg-gray-100 text-gray-700"
|
||||||
<TableCell>
|
}`}
|
||||||
<div className="flex items-center gap-1">
|
>
|
||||||
<Calendar size={14} className="text-gray-400" />
|
{rate.activity || "Standard"}
|
||||||
{new Date(rate.effective_date).toLocaleDateString()}
|
</span>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-gray-500">
|
<span className="text-green-600 font-semibold">
|
||||||
{rate.created_by_name || '-'}
|
₹{rate.rate}
|
||||||
</TableCell>
|
</span>
|
||||||
</TableRow>
|
</TableCell>
|
||||||
))}
|
<TableCell>
|
||||||
</TableBody>
|
<div className="flex items-center gap-1">
|
||||||
</Table>
|
<Calendar size={14} className="text-gray-400" />
|
||||||
</div>
|
{new Date(rate.effective_date).toLocaleDateString()}
|
||||||
) : (
|
</div>
|
||||||
<div className="text-center py-8 text-gray-500">
|
</TableCell>
|
||||||
No rates found. Adjust your filters or check back later.
|
<TableCell className="text-gray-500">
|
||||||
</div>
|
{rate.created_by_name || "-"}
|
||||||
)}
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
No rates found. Adjust your filters or check back later.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,68 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { AlertTriangle, CheckCircle, Clock, RefreshCw, LogIn, LogOut, Search, ArrowUpDown, ArrowUp, ArrowDown, UserX, Edit2, X } from 'lucide-react';
|
import {
|
||||||
import { Card, CardContent } from '../components/ui/Card';
|
AlertTriangle,
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
ArrowDown,
|
||||||
import { Button } from '../components/ui/Button';
|
ArrowUp,
|
||||||
import { Select, Input } from '../components/ui/Input';
|
ArrowUpDown,
|
||||||
import { api } from '../services/api';
|
CheckCircle,
|
||||||
import { useEmployees } from '../hooks/useEmployees';
|
Clock,
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
Edit2,
|
||||||
import type { AttendanceStatus } from '../types';
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
UserX,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../components/ui/Table.tsx";
|
||||||
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
|
import { Input, Select } from "../components/ui/Input.tsx";
|
||||||
|
import { api } from "../services/api.ts";
|
||||||
|
import { useEmployees } from "../hooks/useEmployees.ts";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
import type { AttendanceStatus } from "../types.ts";
|
||||||
|
|
||||||
export const AttendancePage: React.FC = () => {
|
export const AttendancePage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'records' | 'checkin'>('records');
|
const [activeTab, setActiveTab] = useState<"records" | "checkin">("records");
|
||||||
const [attendance, setAttendance] = useState<any[]>([]);
|
const [attendance, setAttendance] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const { employees } = useEmployees();
|
const { employees } = useEmployees();
|
||||||
|
|
||||||
// Check-in form state
|
// Check-in form state
|
||||||
const [selectedEmployee, setSelectedEmployee] = useState('');
|
const [selectedEmployee, setSelectedEmployee] = useState("");
|
||||||
const [workDate, setWorkDate] = useState(new Date().toISOString().split('T')[0]);
|
const [workDate, setWorkDate] = useState(
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
);
|
||||||
const [checkInLoading, setCheckInLoading] = useState(false);
|
const [checkInLoading, setCheckInLoading] = useState(false);
|
||||||
const [employeeStatus, setEmployeeStatus] = useState<any>(null);
|
const [employeeStatus, setEmployeeStatus] = useState<any>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [sortField, setSortField] = useState<'date' | 'employee' | 'status'>('date');
|
const [sortField, setSortField] = useState<"date" | "employee" | "status">(
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
"date",
|
||||||
|
);
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||||
const [editingRecord, setEditingRecord] = useState<number | null>(null);
|
const [editingRecord, setEditingRecord] = useState<number | null>(null);
|
||||||
const [editStatus, setEditStatus] = useState<AttendanceStatus>('CheckedIn');
|
const [editStatus, setEditStatus] = useState<AttendanceStatus>("CheckedIn");
|
||||||
const [editRemark, setEditRemark] = useState('');
|
const [editRemark, setEditRemark] = useState("");
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Fetch attendance records
|
// Fetch attendance records
|
||||||
const fetchAttendance = async () => {
|
const fetchAttendance = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
const data = await api.getAttendance();
|
const data = await api.getAttendance();
|
||||||
setAttendance(data);
|
setAttendance(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch attendance');
|
setError(err.message || "Failed to fetch attendance");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -51,8 +76,9 @@ export const AttendancePage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEmployee && workDate) {
|
if (selectedEmployee && workDate) {
|
||||||
const record = attendance.find(
|
const record = attendance.find(
|
||||||
a => a.employee_id === parseInt(selectedEmployee) &&
|
(a) =>
|
||||||
a.work_date?.split('T')[0] === workDate
|
a.employee_id === parseInt(selectedEmployee) &&
|
||||||
|
a.work_date?.split("T")[0] === workDate,
|
||||||
);
|
);
|
||||||
setEmployeeStatus(record || null);
|
setEmployeeStatus(record || null);
|
||||||
} else {
|
} else {
|
||||||
@@ -62,16 +88,16 @@ export const AttendancePage: React.FC = () => {
|
|||||||
|
|
||||||
const handleCheckIn = async () => {
|
const handleCheckIn = async () => {
|
||||||
if (!selectedEmployee) {
|
if (!selectedEmployee) {
|
||||||
alert('Please select an employee');
|
alert("Please select an employee");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCheckInLoading(true);
|
setCheckInLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.checkIn(parseInt(selectedEmployee), workDate);
|
await api.checkIn(parseInt(selectedEmployee), workDate);
|
||||||
await fetchAttendance();
|
await fetchAttendance();
|
||||||
setEmployeeStatus({ status: 'CheckedIn' });
|
setEmployeeStatus({ status: "CheckedIn" });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to check in');
|
alert(err.message || "Failed to check in");
|
||||||
} finally {
|
} finally {
|
||||||
setCheckInLoading(false);
|
setCheckInLoading(false);
|
||||||
}
|
}
|
||||||
@@ -79,16 +105,16 @@ export const AttendancePage: React.FC = () => {
|
|||||||
|
|
||||||
const handleCheckOut = async () => {
|
const handleCheckOut = async () => {
|
||||||
if (!selectedEmployee) {
|
if (!selectedEmployee) {
|
||||||
alert('Please select an employee');
|
alert("Please select an employee");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCheckInLoading(true);
|
setCheckInLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.checkOut(parseInt(selectedEmployee), workDate);
|
await api.checkOut(parseInt(selectedEmployee), workDate);
|
||||||
await fetchAttendance();
|
await fetchAttendance();
|
||||||
setEmployeeStatus({ status: 'CheckedOut' });
|
setEmployeeStatus({ status: "CheckedOut" });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to check out');
|
alert(err.message || "Failed to check out");
|
||||||
} finally {
|
} finally {
|
||||||
setCheckInLoading(false);
|
setCheckInLoading(false);
|
||||||
}
|
}
|
||||||
@@ -96,16 +122,20 @@ export const AttendancePage: React.FC = () => {
|
|||||||
|
|
||||||
const handleMarkAbsent = async () => {
|
const handleMarkAbsent = async () => {
|
||||||
if (!selectedEmployee) {
|
if (!selectedEmployee) {
|
||||||
alert('Please select an employee');
|
alert("Please select an employee");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCheckInLoading(true);
|
setCheckInLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.markAbsent(parseInt(selectedEmployee), workDate, 'Marked absent by supervisor');
|
await api.markAbsent(
|
||||||
|
parseInt(selectedEmployee),
|
||||||
|
workDate,
|
||||||
|
"Marked absent by supervisor",
|
||||||
|
);
|
||||||
await fetchAttendance();
|
await fetchAttendance();
|
||||||
setEmployeeStatus({ status: 'Absent' });
|
setEmployeeStatus({ status: "Absent" });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to mark absent');
|
alert(err.message || "Failed to mark absent");
|
||||||
} finally {
|
} finally {
|
||||||
setCheckInLoading(false);
|
setCheckInLoading(false);
|
||||||
}
|
}
|
||||||
@@ -116,76 +146,82 @@ export const AttendancePage: React.FC = () => {
|
|||||||
await api.updateAttendanceStatus(recordId, editStatus, editRemark);
|
await api.updateAttendanceStatus(recordId, editStatus, editRemark);
|
||||||
await fetchAttendance();
|
await fetchAttendance();
|
||||||
setEditingRecord(null);
|
setEditingRecord(null);
|
||||||
setEditRemark('');
|
setEditRemark("");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to update status');
|
alert(err.message || "Failed to update status");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEditing = (record: any) => {
|
const startEditing = (record: any) => {
|
||||||
setEditingRecord(record.id);
|
setEditingRecord(record.id);
|
||||||
setEditStatus(record.status);
|
setEditStatus(record.status);
|
||||||
setEditRemark(record.remark || '');
|
setEditRemark(record.remark || "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
setEditingRecord(null);
|
setEditingRecord(null);
|
||||||
setEditRemark('');
|
setEditRemark("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const canEditAttendance = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
|
const canEditAttendance = user?.role === "SuperAdmin" ||
|
||||||
|
user?.role === "Supervisor";
|
||||||
|
|
||||||
const employeeOptions = [
|
const employeeOptions = [
|
||||||
{ value: '', label: 'Select Employee' },
|
{ value: "", label: "Select Employee" },
|
||||||
...employees.filter(e => e.role === 'Employee').map(e => ({
|
...employees.filter((e) => e.role === "Employee").map((e) => ({
|
||||||
value: String(e.id),
|
value: String(e.id),
|
||||||
label: `${e.name} (${e.username})`
|
label: `${e.name} (${e.username})`,
|
||||||
}))
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter and sort attendance records
|
// Filter and sort attendance records
|
||||||
const filteredAndSortedAttendance = useMemo(() => {
|
const filteredAndSortedAttendance = useMemo(() => {
|
||||||
let filtered = attendance;
|
let filtered = attendance;
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter(record =>
|
filtered = filtered.filter((record) =>
|
||||||
record.employee_name?.toLowerCase().includes(query) ||
|
record.employee_name?.toLowerCase().includes(query) ||
|
||||||
record.status?.toLowerCase().includes(query)
|
record.status?.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sorting
|
// Apply sorting
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
let comparison = 0;
|
let comparison = 0;
|
||||||
switch (sortField) {
|
switch (sortField) {
|
||||||
case 'date':
|
case "date":
|
||||||
comparison = new Date(a.work_date).getTime() - new Date(b.work_date).getTime();
|
comparison = new Date(a.work_date).getTime() -
|
||||||
|
new Date(b.work_date).getTime();
|
||||||
break;
|
break;
|
||||||
case 'employee':
|
case "employee":
|
||||||
comparison = (a.employee_name || '').localeCompare(b.employee_name || '');
|
comparison = (a.employee_name || "").localeCompare(
|
||||||
|
b.employee_name || "",
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 'status':
|
case "status":
|
||||||
comparison = (a.status || '').localeCompare(b.status || '');
|
comparison = (a.status || "").localeCompare(b.status || "");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return sortDirection === 'asc' ? comparison : -comparison;
|
return sortDirection === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
}, [attendance, searchQuery, sortField, sortDirection]);
|
}, [attendance, searchQuery, sortField, sortDirection]);
|
||||||
|
|
||||||
const handleSort = (field: 'date' | 'employee' | 'status') => {
|
const handleSort = (field: "date" | "employee" | "status") => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
setSortDirection((prev) => prev === "asc" ? "desc" : "asc");
|
||||||
} else {
|
} else {
|
||||||
setSortField(field);
|
setSortField(field);
|
||||||
setSortDirection('asc');
|
setSortDirection("asc");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const SortIcon = ({ field }: { field: 'date' | 'employee' | 'status' }) => {
|
const SortIcon = ({ field }: { field: "date" | "employee" | "status" }) => {
|
||||||
if (sortField !== field) return <ArrowUpDown size={14} className="ml-1 text-gray-400" />;
|
if (sortField !== field) {
|
||||||
return sortDirection === 'asc'
|
return <ArrowUpDown size={14} className="ml-1 text-gray-400" />;
|
||||||
|
}
|
||||||
|
return sortDirection === "asc"
|
||||||
? <ArrowUp size={14} className="ml-1 text-blue-600" />
|
? <ArrowUp size={14} className="ml-1 text-blue-600" />
|
||||||
: <ArrowDown size={14} className="ml-1 text-blue-600" />;
|
: <ArrowDown size={14} className="ml-1 text-blue-600" />;
|
||||||
};
|
};
|
||||||
@@ -196,21 +232,21 @@ export const AttendancePage: React.FC = () => {
|
|||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<div className="flex space-x-8 px-6">
|
<div className="flex space-x-8 px-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('records')}
|
onClick={() => setActiveTab("records")}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'records'
|
activeTab === "records"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Attendance Records
|
Attendance Records
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('checkin')}
|
onClick={() => setActiveTab("checkin")}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'checkin'
|
activeTab === "checkin"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Check In/Out
|
Check In/Out
|
||||||
@@ -219,11 +255,14 @@ export const AttendancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeTab === 'records' && (
|
{activeTab === "records" && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div className="relative min-w-[300px] flex-1">
|
<div className="relative min-w-[300px] flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by employee name or status..."
|
placeholder="Search by employee name or status..."
|
||||||
@@ -237,7 +276,7 @@ export const AttendancePage: React.FC = () => {
|
|||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 text-sm text-gray-600">
|
<div className="mb-4 text-sm text-gray-600">
|
||||||
Total Records: {filteredAndSortedAttendance.length}
|
Total Records: {filteredAndSortedAttendance.length}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,144 +287,186 @@ export const AttendancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading
|
||||||
<div className="text-center py-8">Loading attendance records...</div>
|
? (
|
||||||
) : filteredAndSortedAttendance.length > 0 ? (
|
<div className="text-center py-8">
|
||||||
<Table>
|
Loading attendance records...
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableHead>ID</TableHead>
|
)
|
||||||
<TableHead>
|
: filteredAndSortedAttendance.length > 0
|
||||||
<button
|
? (
|
||||||
onClick={() => handleSort('employee')}
|
<Table>
|
||||||
className="flex items-center hover:text-blue-600 transition-colors"
|
<TableHeader>
|
||||||
>
|
<TableHead>ID</TableHead>
|
||||||
Employee <SortIcon field="employee" />
|
<TableHead>
|
||||||
</button>
|
<button
|
||||||
</TableHead>
|
onClick={() => handleSort("employee")}
|
||||||
<TableHead>
|
className="flex items-center hover:text-blue-600 transition-colors"
|
||||||
<button
|
>
|
||||||
onClick={() => handleSort('date')}
|
Employee <SortIcon field="employee" />
|
||||||
className="flex items-center hover:text-blue-600 transition-colors"
|
</button>
|
||||||
>
|
</TableHead>
|
||||||
Date <SortIcon field="date" />
|
<TableHead>
|
||||||
</button>
|
<button
|
||||||
</TableHead>
|
onClick={() => handleSort("date")}
|
||||||
<TableHead>Check In</TableHead>
|
className="flex items-center hover:text-blue-600 transition-colors"
|
||||||
<TableHead>Check Out</TableHead>
|
>
|
||||||
<TableHead>
|
Date <SortIcon field="date" />
|
||||||
<button
|
</button>
|
||||||
onClick={() => handleSort('status')}
|
</TableHead>
|
||||||
className="flex items-center hover:text-blue-600 transition-colors"
|
<TableHead>Check In</TableHead>
|
||||||
>
|
<TableHead>Check Out</TableHead>
|
||||||
Status <SortIcon field="status" />
|
<TableHead>
|
||||||
</button>
|
<button
|
||||||
</TableHead>
|
onClick={() => handleSort("status")}
|
||||||
<TableHead>Remark</TableHead>
|
className="flex items-center hover:text-blue-600 transition-colors"
|
||||||
{canEditAttendance && <TableHead>Actions</TableHead>}
|
>
|
||||||
</TableHeader>
|
Status <SortIcon field="status" />
|
||||||
<TableBody>
|
</button>
|
||||||
{filteredAndSortedAttendance.map((record) => (
|
</TableHead>
|
||||||
<TableRow key={record.id}>
|
<TableHead>Remark</TableHead>
|
||||||
<TableCell>{record.id}</TableCell>
|
{canEditAttendance && <TableHead>Actions</TableHead>}
|
||||||
<TableCell>{record.employee_name || '-'}</TableCell>
|
</TableHeader>
|
||||||
<TableCell>{new Date(record.work_date).toLocaleDateString()}</TableCell>
|
<TableBody>
|
||||||
<TableCell>
|
{filteredAndSortedAttendance.map((record) => (
|
||||||
{record.check_in_time
|
<TableRow key={record.id}>
|
||||||
? new Date(record.check_in_time).toLocaleTimeString()
|
<TableCell>{record.id}</TableCell>
|
||||||
: '-'}
|
<TableCell>{record.employee_name || "-"}</TableCell>
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{record.check_out_time
|
|
||||||
? new Date(record.check_out_time).toLocaleTimeString()
|
|
||||||
: '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{editingRecord === record.id ? (
|
|
||||||
<select
|
|
||||||
value={editStatus}
|
|
||||||
onChange={(e) => setEditStatus(e.target.value as AttendanceStatus)}
|
|
||||||
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
|
||||||
>
|
|
||||||
<option value="CheckedIn">Checked In</option>
|
|
||||||
<option value="CheckedOut">Checked Out</option>
|
|
||||||
<option value="Absent">Absent</option>
|
|
||||||
<option value="HalfDay">Half Day</option>
|
|
||||||
<option value="Late">Late</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
record.status === 'CheckedOut' ? 'bg-green-100 text-green-700' :
|
|
||||||
record.status === 'CheckedIn' ? 'bg-blue-100 text-blue-700' :
|
|
||||||
record.status === 'Absent' ? 'bg-red-100 text-red-700' :
|
|
||||||
record.status === 'HalfDay' ? 'bg-orange-100 text-orange-700' :
|
|
||||||
record.status === 'Late' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-gray-100 text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{record.status === 'CheckedOut' ? 'Completed' :
|
|
||||||
record.status === 'CheckedIn' ? 'Checked In' :
|
|
||||||
record.status === 'HalfDay' ? 'Half Day' : record.status}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{editingRecord === record.id ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editRemark}
|
|
||||||
onChange={(e) => setEditRemark(e.target.value)}
|
|
||||||
placeholder="Add remark..."
|
|
||||||
className="px-2 py-1 border border-gray-300 rounded text-sm w-32"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-500 text-sm">{record.remark || '-'}</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
{canEditAttendance && (
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{editingRecord === record.id ? (
|
{new Date(record.work_date).toLocaleDateString()}
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdateStatus(record.id)}
|
|
||||||
className="p-1 text-green-600 hover:bg-green-50 rounded"
|
|
||||||
title="Save"
|
|
||||||
>
|
|
||||||
<CheckCircle size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelEditing}
|
|
||||||
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
|
||||||
title="Cancel"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => startEditing(record)}
|
|
||||||
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
|
||||||
title="Edit Status"
|
|
||||||
>
|
|
||||||
<Edit2 size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
<TableCell>
|
||||||
</TableRow>
|
{record.check_in_time
|
||||||
))}
|
? new Date(record.check_in_time)
|
||||||
</TableBody>
|
.toLocaleTimeString()
|
||||||
</Table>
|
: "-"}
|
||||||
) : (
|
</TableCell>
|
||||||
<div className="text-center py-8 text-gray-500">
|
<TableCell>
|
||||||
{searchQuery ? 'No matching records found' : 'No attendance records found'}
|
{record.check_out_time
|
||||||
</div>
|
? new Date(record.check_out_time)
|
||||||
)}
|
.toLocaleTimeString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{editingRecord === record.id
|
||||||
|
? (
|
||||||
|
<select
|
||||||
|
value={editStatus}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditStatus(
|
||||||
|
e.target.value as AttendanceStatus,
|
||||||
|
)}
|
||||||
|
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
>
|
||||||
|
<option value="CheckedIn">Checked In</option>
|
||||||
|
<option value="CheckedOut">
|
||||||
|
Checked Out
|
||||||
|
</option>
|
||||||
|
<option value="Absent">Absent</option>
|
||||||
|
<option value="HalfDay">Half Day</option>
|
||||||
|
<option value="Late">Late</option>
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
record.status === "CheckedOut"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: record.status === "CheckedIn"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: record.status === "Absent"
|
||||||
|
? "bg-red-100 text-red-700"
|
||||||
|
: record.status === "HalfDay"
|
||||||
|
? "bg-orange-100 text-orange-700"
|
||||||
|
: record.status === "Late"
|
||||||
|
? "bg-yellow-100 text-yellow-700"
|
||||||
|
: "bg-gray-100 text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{record.status === "CheckedOut"
|
||||||
|
? "Completed"
|
||||||
|
: record.status === "CheckedIn"
|
||||||
|
? "Checked In"
|
||||||
|
: record.status === "HalfDay"
|
||||||
|
? "Half Day"
|
||||||
|
: record.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{editingRecord === record.id
|
||||||
|
? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editRemark}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditRemark(e.target.value)}
|
||||||
|
placeholder="Add remark..."
|
||||||
|
className="px-2 py-1 border border-gray-300 rounded text-sm w-32"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span className="text-gray-500 text-sm">
|
||||||
|
{record.remark || "-"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
{canEditAttendance && (
|
||||||
|
<TableCell>
|
||||||
|
{editingRecord === record.id
|
||||||
|
? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdateStatus(record.id)}
|
||||||
|
className="p-1 text-green-600 hover:bg-green-50 rounded"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
onClick={() => startEditing(record)}
|
||||||
|
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="Edit Status"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
{searchQuery
|
||||||
|
? "No matching records found"
|
||||||
|
: "No attendance records found"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'checkin' && (
|
{activeTab === "checkin" && (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">Check In / Check Out Management</h3>
|
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
||||||
<p className="text-sm text-gray-600 mb-6">Manage employee attendance</p>
|
Check In / Check Out Management
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
Manage employee attendance
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
@@ -404,66 +485,91 @@ export const AttendancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedEmployee && (
|
{selectedEmployee && (
|
||||||
<div className={`border rounded-md p-4 flex items-start ${
|
<div
|
||||||
employeeStatus?.status === 'CheckedIn'
|
className={`border rounded-md p-4 flex items-start ${
|
||||||
? 'bg-blue-50 border-blue-200'
|
employeeStatus?.status === "CheckedIn"
|
||||||
: employeeStatus?.status === 'CheckedOut'
|
? "bg-blue-50 border-blue-200"
|
||||||
? 'bg-green-50 border-green-200'
|
: employeeStatus?.status === "CheckedOut"
|
||||||
: 'bg-yellow-50 border-yellow-200'
|
? "bg-green-50 border-green-200"
|
||||||
}`}>
|
: "bg-yellow-50 border-yellow-200"
|
||||||
{employeeStatus?.status === 'CheckedIn' ? (
|
}`}
|
||||||
<>
|
>
|
||||||
<Clock size={20} className="text-blue-600 mr-2 flex-shrink-0 mt-0.5" />
|
{employeeStatus?.status === "CheckedIn"
|
||||||
<p className="text-sm text-blue-800">
|
? (
|
||||||
Employee is currently checked in. Check-in time: {
|
<>
|
||||||
employeeStatus.check_in_time
|
<Clock
|
||||||
? new Date(employeeStatus.check_in_time).toLocaleTimeString()
|
size={20}
|
||||||
: 'N/A'
|
className="text-blue-600 mr-2 flex-shrink-0 mt-0.5"
|
||||||
}
|
/>
|
||||||
</p>
|
<p className="text-sm text-blue-800">
|
||||||
</>
|
Employee is currently checked in. Check-in time:
|
||||||
) : employeeStatus?.status === 'CheckedOut' ? (
|
{" "}
|
||||||
<>
|
{employeeStatus.check_in_time
|
||||||
<CheckCircle size={20} className="text-green-600 mr-2 flex-shrink-0 mt-0.5" />
|
? new Date(employeeStatus.check_in_time)
|
||||||
<p className="text-sm text-green-800">
|
.toLocaleTimeString()
|
||||||
Employee has completed attendance for this date.
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
)
|
||||||
<>
|
: employeeStatus?.status === "CheckedOut"
|
||||||
<AlertTriangle size={20} className="text-yellow-600 mr-2 flex-shrink-0 mt-0.5" />
|
? (
|
||||||
<p className="text-sm text-yellow-800">Employee has not checked in for this date</p>
|
<>
|
||||||
</>
|
<CheckCircle
|
||||||
)}
|
size={20}
|
||||||
|
className="text-green-600 mr-2 flex-shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-green-800">
|
||||||
|
Employee has completed attendance for this date.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<AlertTriangle
|
||||||
|
size={20}
|
||||||
|
className="text-yellow-600 mr-2 flex-shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
Employee has not checked in for this date
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-center gap-4 pt-4">
|
<div className="flex justify-center gap-4 pt-4">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleCheckIn}
|
onClick={handleCheckIn}
|
||||||
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status === 'CheckedIn' || employeeStatus?.status === 'CheckedOut' || employeeStatus?.status === 'Absent'}
|
disabled={checkInLoading || !selectedEmployee ||
|
||||||
|
employeeStatus?.status === "CheckedIn" ||
|
||||||
|
employeeStatus?.status === "CheckedOut" ||
|
||||||
|
employeeStatus?.status === "Absent"}
|
||||||
>
|
>
|
||||||
<LogIn size={16} className="mr-2" />
|
<LogIn size={16} className="mr-2" />
|
||||||
{checkInLoading ? 'Processing...' : 'Check In'}
|
{checkInLoading ? "Processing..." : "Check In"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCheckOut}
|
onClick={handleCheckOut}
|
||||||
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status !== 'CheckedIn'}
|
disabled={checkInLoading || !selectedEmployee ||
|
||||||
|
employeeStatus?.status !== "CheckedIn"}
|
||||||
>
|
>
|
||||||
<LogOut size={16} className="mr-2" />
|
<LogOut size={16} className="mr-2" />
|
||||||
{checkInLoading ? 'Processing...' : 'Check Out'}
|
{checkInLoading ? "Processing..." : "Check Out"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={handleMarkAbsent}
|
onClick={handleMarkAbsent}
|
||||||
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status === 'CheckedIn' || employeeStatus?.status === 'CheckedOut' || employeeStatus?.status === 'Absent'}
|
disabled={checkInLoading || !selectedEmployee ||
|
||||||
|
employeeStatus?.status === "CheckedIn" ||
|
||||||
|
employeeStatus?.status === "CheckedOut" ||
|
||||||
|
employeeStatus?.status === "Absent"}
|
||||||
>
|
>
|
||||||
<UserX size={16} className="mr-2" />
|
<UserX size={16} className="mr-2" />
|
||||||
{checkInLoading ? 'Processing...' : 'Mark Absent'}
|
{checkInLoading ? "Processing..." : "Mark Absent"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,140 +1,159 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowRightLeft,
|
|
||||||
Plus,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
Clock,
|
|
||||||
Building2,
|
|
||||||
User,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Building2,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Filter
|
User,
|
||||||
} from 'lucide-react';
|
XCircle,
|
||||||
import { Card, CardContent } from '../components/ui/Card';
|
} from "lucide-react";
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||||
import { Button } from '../components/ui/Button';
|
import {
|
||||||
import { Select, Input } from '../components/ui/Input';
|
Table,
|
||||||
import { api } from '../services/api';
|
TableBody,
|
||||||
import { useEmployees } from '../hooks/useEmployees';
|
TableCell,
|
||||||
import { useDepartments } from '../hooks/useDepartments';
|
TableHead,
|
||||||
import type { EmployeeSwap, SwapReason, SwapStatus } from '../types';
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../components/ui/Table.tsx";
|
||||||
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
|
import { Input, Select } from "../components/ui/Input.tsx";
|
||||||
|
import { api } from "../services/api.ts";
|
||||||
|
import { useEmployees } from "../hooks/useEmployees.ts";
|
||||||
|
import { useDepartments } from "../hooks/useDepartments.ts";
|
||||||
|
import type { EmployeeSwap, SwapReason, SwapStatus } from "../types.ts";
|
||||||
|
|
||||||
export const EmployeeSwapPage: React.FC = () => {
|
export const EmployeeSwapPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'list' | 'create'>('list');
|
const [activeTab, setActiveTab] = useState<"list" | "create">("list");
|
||||||
const [swaps, setSwaps] = useState<EmployeeSwap[]>([]);
|
const [swaps, setSwaps] = useState<EmployeeSwap[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<SwapStatus | ''>('');
|
const [statusFilter, setStatusFilter] = useState<SwapStatus | "">("");
|
||||||
|
|
||||||
const { employees } = useEmployees();
|
const { employees } = useEmployees();
|
||||||
const { departments } = useDepartments();
|
const { departments } = useDepartments();
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
employeeId: '',
|
employeeId: "",
|
||||||
targetDepartmentId: '',
|
targetDepartmentId: "",
|
||||||
targetContractorId: '',
|
targetContractorId: "",
|
||||||
swapReason: '' as SwapReason | '',
|
swapReason: "" as SwapReason | "",
|
||||||
reasonDetails: '',
|
reasonDetails: "",
|
||||||
workCompletionPercentage: 0,
|
workCompletionPercentage: 0,
|
||||||
swapDate: new Date().toISOString().split('T')[0],
|
swapDate: new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const fetchSwaps = async () => {
|
const fetchSwaps = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params: { status?: string } = {};
|
const params: { status?: string } = {};
|
||||||
if (statusFilter) params.status = statusFilter;
|
if (statusFilter) params.status = statusFilter;
|
||||||
const data = await api.getEmployeeSwaps(params);
|
const data = await api.getEmployeeSwaps(params);
|
||||||
setSwaps(data);
|
setSwaps(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch swaps');
|
setError(err.message || "Failed to fetch swaps");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [statusFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSwaps();
|
fetchSwaps();
|
||||||
}, [statusFilter]);
|
}, [fetchSwaps]);
|
||||||
|
|
||||||
const handleCreateSwap = async (e: React.FormEvent) => {
|
const handleCreateSwap = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.employeeId || !formData.targetDepartmentId || !formData.swapReason) {
|
if (
|
||||||
alert('Please fill in all required fields');
|
!formData.employeeId || !formData.targetDepartmentId ||
|
||||||
|
!formData.swapReason
|
||||||
|
) {
|
||||||
|
alert("Please fill in all required fields");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await api.createEmployeeSwap({
|
await api.createEmployeeSwap({
|
||||||
employeeId: parseInt(formData.employeeId),
|
employeeId: parseInt(formData.employeeId),
|
||||||
targetDepartmentId: parseInt(formData.targetDepartmentId),
|
targetDepartmentId: parseInt(formData.targetDepartmentId),
|
||||||
targetContractorId: formData.targetContractorId ? parseInt(formData.targetContractorId) : undefined,
|
targetContractorId: formData.targetContractorId
|
||||||
|
? parseInt(formData.targetContractorId)
|
||||||
|
: undefined,
|
||||||
swapReason: formData.swapReason as SwapReason,
|
swapReason: formData.swapReason as SwapReason,
|
||||||
reasonDetails: formData.reasonDetails || undefined,
|
reasonDetails: formData.reasonDetails || undefined,
|
||||||
workCompletionPercentage: formData.workCompletionPercentage,
|
workCompletionPercentage: formData.workCompletionPercentage,
|
||||||
swapDate: formData.swapDate,
|
swapDate: formData.swapDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset form and switch to list
|
// Reset form and switch to list
|
||||||
setFormData({
|
setFormData({
|
||||||
employeeId: '',
|
employeeId: "",
|
||||||
targetDepartmentId: '',
|
targetDepartmentId: "",
|
||||||
targetContractorId: '',
|
targetContractorId: "",
|
||||||
swapReason: '',
|
swapReason: "",
|
||||||
reasonDetails: '',
|
reasonDetails: "",
|
||||||
workCompletionPercentage: 0,
|
workCompletionPercentage: 0,
|
||||||
swapDate: new Date().toISOString().split('T')[0],
|
swapDate: new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
setActiveTab('list');
|
setActiveTab("list");
|
||||||
await fetchSwaps();
|
await fetchSwaps();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to create swap');
|
alert(err.message || "Failed to create swap");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteSwap = async (id: number) => {
|
const handleCompleteSwap = async (id: number) => {
|
||||||
if (!confirm('Complete this swap and return employee to original department?')) return;
|
if (
|
||||||
|
!confirm("Complete this swap and return employee to original department?")
|
||||||
|
) return;
|
||||||
try {
|
try {
|
||||||
await api.completeEmployeeSwap(id);
|
await api.completeEmployeeSwap(id);
|
||||||
await fetchSwaps();
|
await fetchSwaps();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to complete swap');
|
alert(err.message || "Failed to complete swap");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelSwap = async (id: number) => {
|
const handleCancelSwap = async (id: number) => {
|
||||||
if (!confirm('Cancel this swap and return employee to original department?')) return;
|
if (
|
||||||
|
!confirm("Cancel this swap and return employee to original department?")
|
||||||
|
) return;
|
||||||
try {
|
try {
|
||||||
await api.cancelEmployeeSwap(id);
|
await api.cancelEmployeeSwap(id);
|
||||||
await fetchSwaps();
|
await fetchSwaps();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to cancel swap');
|
alert(err.message || "Failed to cancel swap");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter employees (only show employees)
|
// Filter employees (only show employees)
|
||||||
const employeeList = employees.filter(e => e.role === 'Employee');
|
const employeeList = employees.filter((e) => e.role === "Employee");
|
||||||
|
|
||||||
// Get contractors for selected target department
|
// Get contractors for selected target department
|
||||||
const targetContractors = employees.filter(
|
const targetContractors = employees.filter(
|
||||||
e => e.role === 'Contractor' &&
|
(e) =>
|
||||||
e.department_id === parseInt(formData.targetDepartmentId)
|
e.role === "Contractor" &&
|
||||||
|
e.department_id === parseInt(formData.targetDepartmentId),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get selected employee details
|
// Get selected employee details
|
||||||
const selectedEmployee = employeeList.find(e => e.id === parseInt(formData.employeeId));
|
const selectedEmployee = employeeList.find((e) =>
|
||||||
|
e.id === parseInt(formData.employeeId)
|
||||||
|
);
|
||||||
|
|
||||||
// Filter swaps based on search
|
// Filter swaps based on search
|
||||||
const filteredSwaps = swaps.filter(swap => {
|
const filteredSwaps = swaps.filter((swap) => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -146,29 +165,47 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
|
|
||||||
const getStatusBadge = (status: SwapStatus) => {
|
const getStatusBadge = (status: SwapStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Active':
|
case "Active":
|
||||||
return <span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700">Active</span>;
|
return (
|
||||||
case 'Completed':
|
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700">
|
||||||
return <span className="px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">Completed</span>;
|
Active
|
||||||
case 'Cancelled':
|
</span>
|
||||||
return <span className="px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-700">Cancelled</span>;
|
);
|
||||||
|
case "Completed":
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case "Cancelled":
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-700">
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getReasonBadge = (reason: SwapReason) => {
|
const getReasonBadge = (reason: SwapReason) => {
|
||||||
const colors: Record<SwapReason, string> = {
|
const colors: Record<SwapReason, string> = {
|
||||||
'LeftWork': 'bg-orange-100 text-orange-700',
|
"LeftWork": "bg-orange-100 text-orange-700",
|
||||||
'Sick': 'bg-red-100 text-red-700',
|
"Sick": "bg-red-100 text-red-700",
|
||||||
'FinishedEarly': 'bg-green-100 text-green-700',
|
"FinishedEarly": "bg-green-100 text-green-700",
|
||||||
'Other': 'bg-gray-100 text-gray-700',
|
"Other": "bg-gray-100 text-gray-700",
|
||||||
};
|
};
|
||||||
const labels: Record<SwapReason, string> = {
|
const labels: Record<SwapReason, string> = {
|
||||||
'LeftWork': 'Left Work',
|
"LeftWork": "Left Work",
|
||||||
'Sick': 'Sick',
|
"Sick": "Sick",
|
||||||
'FinishedEarly': 'Finished Early',
|
"FinishedEarly": "Finished Early",
|
||||||
'Other': 'Other',
|
"Other": "Other",
|
||||||
};
|
};
|
||||||
return <span className={`px-2 py-1 rounded text-xs font-medium ${colors[reason]}`}>{labels[reason]}</span>;
|
return (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${colors[reason]}`}
|
||||||
|
>
|
||||||
|
{labels[reason]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -182,8 +219,12 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<ArrowRightLeft className="text-purple-600" size={24} />
|
<ArrowRightLeft className="text-purple-600" size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-800">Employee Work Swap</h1>
|
<h1 className="text-xl font-bold text-gray-800">
|
||||||
<p className="text-sm text-gray-500">Transfer employees between departments temporarily</p>
|
Employee Work Swap
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Transfer employees between departments temporarily
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,21 +234,21 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<div className="flex space-x-8 px-6">
|
<div className="flex space-x-8 px-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('list')}
|
onClick={() => setActiveTab("list")}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'list'
|
activeTab === "list"
|
||||||
? 'border-purple-500 text-purple-600'
|
? "border-purple-500 text-purple-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Swap History
|
Swap History
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('create')}
|
onClick={() => setActiveTab("create")}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||||
activeTab === 'create'
|
activeTab === "create"
|
||||||
? 'border-purple-500 text-purple-600'
|
? "border-purple-500 text-purple-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
@@ -217,12 +258,15 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeTab === 'list' && (
|
{activeTab === "list" && (
|
||||||
<div>
|
<div>
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-4 mb-6">
|
<div className="flex gap-4 mb-6">
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by employee or department..."
|
placeholder="Search by employee or department..."
|
||||||
@@ -235,7 +279,8 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<Filter size={18} className="text-gray-400" />
|
<Filter size={18} className="text-gray-400" />
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value as SwapStatus | '')}
|
onChange={(e) =>
|
||||||
|
setStatusFilter(e.target.value as SwapStatus | "")}
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500"
|
||||||
>
|
>
|
||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
@@ -258,7 +303,7 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<span className="text-sm font-medium">Active</span>
|
<span className="text-sm font-medium">Active</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-blue-700">
|
<div className="text-2xl font-bold text-blue-700">
|
||||||
{swaps.filter(s => s.status === 'Active').length}
|
{swaps.filter((s) => s.status === "Active").length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 rounded-lg p-4">
|
<div className="bg-green-50 rounded-lg p-4">
|
||||||
@@ -267,7 +312,7 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<span className="text-sm font-medium">Completed</span>
|
<span className="text-sm font-medium">Completed</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-green-700">
|
<div className="text-2xl font-bold text-green-700">
|
||||||
{swaps.filter(s => s.status === 'Completed').length}
|
{swaps.filter((s) => s.status === "Completed").length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-red-50 rounded-lg p-4">
|
<div className="bg-red-50 rounded-lg p-4">
|
||||||
@@ -276,7 +321,7 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<span className="text-sm font-medium">Cancelled</span>
|
<span className="text-sm font-medium">Cancelled</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-red-700">
|
<div className="text-2xl font-bold text-red-700">
|
||||||
{swaps.filter(s => s.status === 'Cancelled').length}
|
{swaps.filter((s) => s.status === "Cancelled").length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-50 rounded-lg p-4">
|
<div className="bg-purple-50 rounded-lg p-4">
|
||||||
@@ -284,7 +329,9 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<ArrowRightLeft size={18} />
|
<ArrowRightLeft size={18} />
|
||||||
<span className="text-sm font-medium">Total Swaps</span>
|
<span className="text-sm font-medium">Total Swaps</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-purple-700">{swaps.length}</div>
|
<div className="text-2xl font-bold text-purple-700">
|
||||||
|
{swaps.length}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,125 +341,155 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading
|
||||||
<div className="text-center py-8">
|
? (
|
||||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600"></div>
|
<div className="text-center py-8">
|
||||||
<span className="ml-2 text-gray-600">Loading swaps...</span>
|
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600">
|
||||||
</div>
|
</div>
|
||||||
) : filteredSwaps.length > 0 ? (
|
<span className="ml-2 text-gray-600">Loading swaps...</span>
|
||||||
<Table>
|
</div>
|
||||||
<TableHeader>
|
)
|
||||||
<TableHead>Employee</TableHead>
|
: filteredSwaps.length > 0
|
||||||
<TableHead>From → To</TableHead>
|
? (
|
||||||
<TableHead>Reason</TableHead>
|
<Table>
|
||||||
<TableHead>Completion %</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Swap Date</TableHead>
|
<TableHead>Employee</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>From → To</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Reason</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Completion %</TableHead>
|
||||||
<TableBody>
|
<TableHead>Swap Date</TableHead>
|
||||||
{filteredSwaps.map((swap) => (
|
<TableHead>Status</TableHead>
|
||||||
<TableRow key={swap.id}>
|
<TableHead>Actions</TableHead>
|
||||||
<TableCell>
|
</TableHeader>
|
||||||
<div className="flex items-center gap-2">
|
<TableBody>
|
||||||
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
{filteredSwaps.map((swap) => (
|
||||||
<User size={16} className="text-purple-600" />
|
<TableRow key={swap.id}>
|
||||||
</div>
|
<TableCell>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium text-gray-800">{swap.employee_name}</div>
|
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
<div className="text-xs text-gray-500">
|
<User size={16} className="text-purple-600" />
|
||||||
{swap.original_contractor_name && `Under: ${swap.original_contractor_name}`}
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-800">
|
||||||
|
{swap.employee_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{swap.original_contractor_name &&
|
||||||
|
`Under: ${swap.original_contractor_name}`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-gray-600">
|
||||||
<span className="text-gray-600">{swap.original_department_name}</span>
|
{swap.original_department_name}
|
||||||
<ArrowRightLeft size={14} className="text-gray-400" />
|
</span>
|
||||||
<span className="font-medium text-purple-600">{swap.target_department_name}</span>
|
<ArrowRightLeft
|
||||||
</div>
|
size={14}
|
||||||
{swap.target_contractor_name && (
|
className="text-gray-400"
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
/>
|
||||||
New contractor: {swap.target_contractor_name}
|
<span className="font-medium text-purple-600">
|
||||||
|
{swap.target_department_name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{swap.target_contractor_name && (
|
||||||
</TableCell>
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
<TableCell>
|
New contractor: {swap.target_contractor_name}
|
||||||
<div className="space-y-1">
|
|
||||||
{getReasonBadge(swap.swap_reason)}
|
|
||||||
{swap.reason_details && (
|
|
||||||
<div className="text-xs text-gray-500 max-w-[150px] truncate" title={swap.reason_details}>
|
|
||||||
{swap.reason_details}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
{getReasonBadge(swap.swap_reason)}
|
||||||
<div className="w-16 bg-gray-200 rounded-full h-2">
|
{swap.reason_details && (
|
||||||
<div
|
<div
|
||||||
className="bg-purple-600 h-2 rounded-full"
|
className="text-xs text-gray-500 max-w-[150px] truncate"
|
||||||
style={{ width: `${swap.work_completion_percentage}%` }}
|
title={swap.reason_details}
|
||||||
/>
|
>
|
||||||
|
{swap.reason_details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-600">{swap.work_completion_percentage}%</span>
|
</TableCell>
|
||||||
</div>
|
<TableCell>
|
||||||
</TableCell>
|
<div className="flex items-center gap-2">
|
||||||
<TableCell>
|
<div className="w-16 bg-gray-200 rounded-full h-2">
|
||||||
<div className="text-sm text-gray-600">
|
<div
|
||||||
{new Date(swap.swap_date).toLocaleDateString()}
|
className="bg-purple-600 h-2 rounded-full"
|
||||||
</div>
|
style={{
|
||||||
<div className="text-xs text-gray-400">
|
width:
|
||||||
by {swap.swapped_by_name}
|
`${swap.work_completion_percentage}%`,
|
||||||
</div>
|
}}
|
||||||
</TableCell>
|
/>
|
||||||
<TableCell>{getStatusBadge(swap.status)}</TableCell>
|
</div>
|
||||||
<TableCell>
|
<span className="text-sm text-gray-600">
|
||||||
{swap.status === 'Active' && (
|
{swap.work_completion_percentage}%
|
||||||
<div className="flex gap-1">
|
</span>
|
||||||
<button
|
|
||||||
onClick={() => handleCompleteSwap(swap.id)}
|
|
||||||
className="p-1.5 text-green-600 hover:bg-green-50 rounded"
|
|
||||||
title="Complete & Return"
|
|
||||||
>
|
|
||||||
<CheckCircle size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCancelSwap(swap.id)}
|
|
||||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded"
|
|
||||||
title="Cancel Swap"
|
|
||||||
>
|
|
||||||
<XCircle size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
</TableRow>
|
<div className="text-sm text-gray-600">
|
||||||
))}
|
{new Date(swap.swap_date).toLocaleDateString()}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
<div className="text-xs text-gray-400">
|
||||||
) : (
|
by {swap.swapped_by_name}
|
||||||
<div className="text-center py-12 text-gray-500">
|
</div>
|
||||||
<ArrowRightLeft size={48} className="mx-auto mb-4 text-gray-300" />
|
</TableCell>
|
||||||
<p>No swap records found</p>
|
<TableCell>{getStatusBadge(swap.status)}</TableCell>
|
||||||
<Button
|
<TableCell>
|
||||||
className="mt-4"
|
{swap.status === "Active" && (
|
||||||
onClick={() => setActiveTab('create')}
|
<div className="flex gap-1">
|
||||||
>
|
<button
|
||||||
<Plus size={16} className="mr-2" />
|
onClick={() => handleCompleteSwap(swap.id)}
|
||||||
Create First Swap
|
className="p-1.5 text-green-600 hover:bg-green-50 rounded"
|
||||||
</Button>
|
title="Complete & Return"
|
||||||
</div>
|
>
|
||||||
)}
|
<CheckCircle size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelSwap(swap.id)}
|
||||||
|
className="p-1.5 text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Cancel Swap"
|
||||||
|
>
|
||||||
|
<XCircle size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<ArrowRightLeft
|
||||||
|
size={48}
|
||||||
|
className="mx-auto mb-4 text-gray-300"
|
||||||
|
/>
|
||||||
|
<p>No swap records found</p>
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setActiveTab("create")}
|
||||||
|
>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Create First Swap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'create' && (
|
{activeTab === "create" && (
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-800">Create Employee Swap</h2>
|
<h2 className="text-lg font-semibold text-gray-800">
|
||||||
<p className="text-sm text-gray-500">Transfer an employee to a different department temporarily</p>
|
Create Employee Swap
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Transfer an employee to a different department temporarily
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleCreateSwap} className="space-y-6">
|
<form onSubmit={handleCreateSwap} className="space-y-6">
|
||||||
@@ -425,17 +502,18 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<Select
|
<Select
|
||||||
label="Employee"
|
label="Employee"
|
||||||
value={formData.employeeId}
|
value={formData.employeeId}
|
||||||
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, employeeId: e.target.value })}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select an employee...' },
|
{ value: "", label: "Select an employee..." },
|
||||||
...employeeList.map(e => ({
|
...employeeList.map((e) => ({
|
||||||
value: String(e.id),
|
value: String(e.id),
|
||||||
label: `${e.name} - ${e.department_name || 'No Dept'}`
|
label: `${e.name} - ${e.department_name || "No Dept"}`,
|
||||||
}))
|
})),
|
||||||
]}
|
]}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedEmployee && (
|
{selectedEmployee && (
|
||||||
<div className="mt-3 p-3 bg-white rounded border border-gray-200">
|
<div className="mt-3 p-3 bg-white rounded border border-gray-200">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -443,10 +521,14 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<User size={20} className="text-purple-600" />
|
<User size={20} className="text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-gray-800">{selectedEmployee.name}</div>
|
<div className="font-medium text-gray-800">
|
||||||
|
{selectedEmployee.name}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
Current: {selectedEmployee.department_name || 'No Department'}
|
Current: {selectedEmployee.department_name ||
|
||||||
{selectedEmployee.contractor_name && ` • Under: ${selectedEmployee.contractor_name}`}
|
"No Department"}
|
||||||
|
{selectedEmployee.contractor_name &&
|
||||||
|
` • Under: ${selectedEmployee.contractor_name}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,26 +546,36 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<Select
|
<Select
|
||||||
label="Department"
|
label="Department"
|
||||||
value={formData.targetDepartmentId}
|
value={formData.targetDepartmentId}
|
||||||
onChange={(e) => setFormData({
|
onChange={(e) =>
|
||||||
...formData,
|
setFormData({
|
||||||
targetDepartmentId: e.target.value,
|
...formData,
|
||||||
targetContractorId: '' // Reset contractor when department changes
|
targetDepartmentId: e.target.value,
|
||||||
})}
|
targetContractorId: "", // Reset contractor when department changes
|
||||||
|
})}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select department...' },
|
{ value: "", label: "Select department..." },
|
||||||
...departments
|
...departments
|
||||||
.filter(d => d.id !== selectedEmployee?.department_id)
|
.filter((d) =>
|
||||||
.map(d => ({ value: String(d.id), label: d.name }))
|
d.id !== selectedEmployee?.department_id
|
||||||
|
)
|
||||||
|
.map((d) => ({ value: String(d.id), label: d.name })),
|
||||||
]}
|
]}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Assign to Contractor (Optional)"
|
label="Assign to Contractor (Optional)"
|
||||||
value={formData.targetContractorId}
|
value={formData.targetContractorId}
|
||||||
onChange={(e) => setFormData({ ...formData, targetContractorId: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
targetContractorId: e.target.value,
|
||||||
|
})}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'No contractor' },
|
{ value: "", label: "No contractor" },
|
||||||
...targetContractors.map(c => ({ value: String(c.id), label: c.name }))
|
...targetContractors.map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
disabled={!formData.targetDepartmentId}
|
disabled={!formData.targetDepartmentId}
|
||||||
/>
|
/>
|
||||||
@@ -500,13 +592,20 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<Select
|
<Select
|
||||||
label="Reason"
|
label="Reason"
|
||||||
value={formData.swapReason}
|
value={formData.swapReason}
|
||||||
onChange={(e) => setFormData({ ...formData, swapReason: e.target.value as SwapReason })}
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
swapReason: e.target.value as SwapReason,
|
||||||
|
})}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select reason...' },
|
{ value: "", label: "Select reason..." },
|
||||||
{ value: 'LeftWork', label: 'Left Work Early' },
|
{ value: "LeftWork", label: "Left Work Early" },
|
||||||
{ value: 'Sick', label: 'Sick / Unwell' },
|
{ value: "Sick", label: "Sick / Unwell" },
|
||||||
{ value: 'FinishedEarly', label: 'Finished Work Early' },
|
{
|
||||||
{ value: 'Other', label: 'Other Reason' },
|
value: "FinishedEarly",
|
||||||
|
label: "Finished Work Early",
|
||||||
|
},
|
||||||
|
{ value: "Other", label: "Other Reason" },
|
||||||
]}
|
]}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -521,10 +620,13 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
max="100"
|
max="100"
|
||||||
step="5"
|
step="5"
|
||||||
value={formData.workCompletionPercentage}
|
value={formData.workCompletionPercentage}
|
||||||
onChange={(e) => setFormData({
|
onChange={(e) =>
|
||||||
...formData,
|
setFormData({
|
||||||
workCompletionPercentage: parseInt(e.target.value)
|
...formData,
|
||||||
})}
|
workCompletionPercentage: parseInt(
|
||||||
|
e.target.value,
|
||||||
|
),
|
||||||
|
})}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-700 w-12">
|
<span className="text-sm font-medium text-gray-700 w-12">
|
||||||
@@ -537,7 +639,11 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
label="Additional Details (Optional)"
|
label="Additional Details (Optional)"
|
||||||
value={formData.reasonDetails}
|
value={formData.reasonDetails}
|
||||||
onChange={(e) => setFormData({ ...formData, reasonDetails: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
reasonDetails: e.target.value,
|
||||||
|
})}
|
||||||
placeholder="Provide more context about the swap..."
|
placeholder="Provide more context about the swap..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,35 +659,40 @@ export const EmployeeSwapPage: React.FC = () => {
|
|||||||
label="Date"
|
label="Date"
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.swapDate}
|
value={formData.swapDate}
|
||||||
onChange={(e) => setFormData({ ...formData, swapDate: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, swapDate: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setActiveTab('list')}
|
onClick={() => setActiveTab("list")}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || !formData.employeeId || !formData.targetDepartmentId || !formData.swapReason}
|
disabled={submitting || !formData.employeeId ||
|
||||||
|
!formData.targetDepartmentId || !formData.swapReason}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting
|
||||||
<>
|
? (
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<>
|
||||||
Creating...
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2">
|
||||||
</>
|
</div>
|
||||||
) : (
|
Creating...
|
||||||
<>
|
</>
|
||||||
<ArrowRightLeft size={16} className="mr-2" />
|
)
|
||||||
Create Swap
|
: (
|
||||||
</>
|
<>
|
||||||
)}
|
<ArrowRightLeft size={16} className="mr-2" />
|
||||||
|
Create Swap
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,34 +1,42 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
import {
|
import {
|
||||||
Users, Lock, Eye, EyeOff, XCircle, Mail, ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle, X, Sparkles, Shield, KeyRound
|
CheckCircle,
|
||||||
} from 'lucide-react';
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
KeyRound,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export const LoginPage: React.FC = () => {
|
export const LoginPage: React.FC = () => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const [showError, setShowError] = useState(false);
|
const [showError, setShowError] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
|
||||||
// Forgot password modal state
|
|
||||||
const [showForgotModal, setShowForgotModal] = useState(false);
|
const [showForgotModal, setShowForgotModal] = useState(false);
|
||||||
const [forgotEmail, setForgotEmail] = useState('');
|
const [forgotEmail, setForgotEmail] = useState("");
|
||||||
const [forgotLoading, setForgotLoading] = useState(false);
|
const [forgotLoading, setForgotLoading] = useState(false);
|
||||||
const [forgotSuccess, setForgotSuccess] = useState(false);
|
const [forgotSuccess, setForgotSuccess] = useState(false);
|
||||||
const [forgotError, setForgotError] = useState('');
|
const [forgotError, setForgotError] = useState("");
|
||||||
|
|
||||||
// Auto-hide error after 5 seconds
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
setShowError(true);
|
setShowError(true);
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setShowError(false);
|
setShowError(false);
|
||||||
setTimeout(() => setError(''), 300);
|
setTimeout(() => setError(""), 300);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
@@ -36,18 +44,20 @@ export const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
const errorMessage = error.message?.includes('401') || error.message?.includes('Unauthorized') || error.message?.includes('Invalid')
|
const errorMessage = error.message?.includes("401") ||
|
||||||
? 'Invalid username or password'
|
error.message?.includes("Unauthorized") ||
|
||||||
: error.message || 'Login failed. Please check your credentials.';
|
error.message?.includes("Invalid")
|
||||||
|
? "Invalid username or password"
|
||||||
|
: error.message || "Login failed. Please check your credentials.";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error('Login error:', err);
|
console.error("Login error:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -56,15 +66,15 @@ export const LoginPage: React.FC = () => {
|
|||||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setForgotLoading(true);
|
setForgotLoading(true);
|
||||||
setForgotError('');
|
setForgotError("");
|
||||||
|
|
||||||
// Simulate API call (replace with actual API call)
|
// Simulate API call (replace with actual API call)
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
// In a real app, you'd call: await api.requestPasswordReset(forgotEmail);
|
// In a real app, you'd call: await api.requestPasswordReset(forgotEmail);
|
||||||
setForgotSuccess(true);
|
setForgotSuccess(true);
|
||||||
} catch {
|
} catch {
|
||||||
setForgotError('Failed to send reset email. Please try again.');
|
setForgotError("Failed to send reset email. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setForgotLoading(false);
|
setForgotLoading(false);
|
||||||
}
|
}
|
||||||
@@ -72,9 +82,9 @@ export const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
const closeForgotModal = () => {
|
const closeForgotModal = () => {
|
||||||
setShowForgotModal(false);
|
setShowForgotModal(false);
|
||||||
setForgotEmail('');
|
setForgotEmail("");
|
||||||
setForgotSuccess(false);
|
setForgotSuccess(false);
|
||||||
setForgotError('');
|
setForgotError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -109,10 +119,15 @@ export const LoginPage: React.FC = () => {
|
|||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl shadow-lg mb-4 relative">
|
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl shadow-lg mb-4 relative">
|
||||||
<Shield size={40} className="text-white" strokeWidth={1.5} />
|
<Shield size={40} className="text-white" strokeWidth={1.5} />
|
||||||
<Sparkles size={16} className="text-yellow-300 absolute -top-1 -right-1 animate-pulse" />
|
<Sparkles
|
||||||
|
size={16}
|
||||||
|
className="text-yellow-300 absolute -top-1 -right-1 animate-pulse"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">Welcome Back</h1>
|
<h1 className="text-2xl font-bold text-white mb-1">Welcome Back</h1>
|
||||||
<p className="text-blue-200/70 text-sm">Sign in to your account to continue</p>
|
<p className="text-blue-200/70 text-sm">
|
||||||
|
Sign in to your account to continue
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
@@ -139,7 +154,7 @@ export const LoginPage: React.FC = () => {
|
|||||||
<Lock size={20} />
|
<Lock size={20} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? "text" : "password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
@@ -165,7 +180,9 @@ export const LoginPage: React.FC = () => {
|
|||||||
onChange={(e) => setRememberMe(e.target.checked)}
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
className="w-4 h-4 bg-white/10 border-white/30 rounded text-blue-500 focus:ring-blue-400/50 focus:ring-offset-0"
|
className="w-4 h-4 bg-white/10 border-white/30 rounded text-blue-500 focus:ring-blue-400/50 focus:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-blue-200/70 group-hover:text-blue-200 transition-colors">Remember me</span>
|
<span className="ml-2 text-blue-200/70 group-hover:text-blue-200 transition-colors">
|
||||||
|
Remember me
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -182,17 +199,22 @@ export const LoginPage: React.FC = () => {
|
|||||||
disabled={loading || !username || !password}
|
disabled={loading || !username || !password}
|
||||||
className="w-full bg-gradient-to-r from-blue-500 via-blue-600 to-purple-600 hover:from-blue-600 hover:via-blue-700 hover:to-purple-700 text-white font-semibold py-4 rounded-xl shadow-lg hover:shadow-blue-500/25 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
|
className="w-full bg-gradient-to-r from-blue-500 via-blue-600 to-purple-600 hover:from-blue-600 hover:via-blue-700 hover:to-purple-700 text-white font-semibold py-4 rounded-xl shadow-lg hover:shadow-blue-500/25 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading
|
||||||
<>
|
? (
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<>
|
||||||
Signing in...
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
</>
|
Signing in...
|
||||||
) : (
|
</>
|
||||||
<>
|
)
|
||||||
Sign In
|
: (
|
||||||
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
<>
|
||||||
</>
|
Sign In
|
||||||
)}
|
<ArrowRight
|
||||||
|
size={18}
|
||||||
|
className="group-hover:translate-x-1 transition-transform"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -217,7 +239,11 @@ export const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Error Toast */}
|
{/* Error Toast */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 transition-all duration-300 ${showError ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}`}>
|
<div
|
||||||
|
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 transition-all duration-300 ${
|
||||||
|
showError ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-4"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="bg-gradient-to-r from-red-500 to-red-600 text-white px-6 py-4 rounded-2xl shadow-2xl flex items-center gap-3 min-w-[320px] border border-red-400/30">
|
<div className="bg-gradient-to-r from-red-500 to-red-600 text-white px-6 py-4 rounded-2xl shadow-2xl flex items-center gap-3 min-w-[320px] border border-red-400/30">
|
||||||
<div className="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0">
|
<div className="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<XCircle size={24} />
|
<XCircle size={24} />
|
||||||
@@ -234,11 +260,11 @@ export const LoginPage: React.FC = () => {
|
|||||||
{showForgotModal && (
|
{showForgotModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={closeForgotModal}
|
onClick={closeForgotModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="relative bg-slate-800/90 backdrop-blur-xl rounded-2xl shadow-2xl p-8 w-full max-w-md border border-white/10 animate-in fade-in zoom-in duration-200">
|
<div className="relative bg-slate-800/90 backdrop-blur-xl rounded-2xl shadow-2xl p-8 w-full max-w-md border border-white/10 animate-in fade-in zoom-in duration-200">
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
@@ -249,91 +275,105 @@ export const LoginPage: React.FC = () => {
|
|||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!forgotSuccess ? (
|
{!forgotSuccess
|
||||||
<>
|
? (
|
||||||
{/* Header */}
|
<>
|
||||||
<div className="text-center mb-6">
|
{/* Header */}
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl shadow-lg mb-4">
|
<div className="text-center mb-6">
|
||||||
<KeyRound size={32} className="text-white" />
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl shadow-lg mb-4">
|
||||||
</div>
|
<KeyRound size={32} className="text-white" />
|
||||||
<h2 className="text-xl font-bold text-white mb-2">Forgot Password?</h2>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
Enter your email address and we'll send you instructions to reset your password.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form onSubmit={handleForgotPassword} className="space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
|
|
||||||
<Mail size={20} />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<h2 className="text-xl font-bold text-white mb-2">
|
||||||
type="email"
|
Forgot Password?
|
||||||
value={forgotEmail}
|
</h2>
|
||||||
onChange={(e) => setForgotEmail(e.target.value)}
|
<p className="text-gray-400 text-sm">
|
||||||
placeholder="Enter your email"
|
Enter your email address and we'll send you instructions
|
||||||
required
|
to reset your password.
|
||||||
className="w-full pl-12 pr-4 py-4 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400/50 transition-all"
|
</p>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{forgotError && (
|
{/* Form */}
|
||||||
<p className="text-red-400 text-sm text-center">{forgotError}</p>
|
<form onSubmit={handleForgotPassword} className="space-y-4">
|
||||||
)}
|
<div className="relative">
|
||||||
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
|
||||||
|
<Mail size={20} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={forgotEmail}
|
||||||
|
onChange={(e) => setForgotEmail(e.target.value)}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
className="w-full pl-12 pr-4 py-4 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
{forgotError && (
|
||||||
type="submit"
|
<p className="text-red-400 text-sm text-center">
|
||||||
disabled={forgotLoading || !forgotEmail}
|
{forgotError}
|
||||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white font-semibold py-4 rounded-xl shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
</p>
|
||||||
>
|
|
||||||
{forgotLoading ? (
|
|
||||||
<>
|
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
Sending...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Mail size={18} />
|
|
||||||
Send Reset Link
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Back to login */}
|
<button
|
||||||
<button
|
type="submit"
|
||||||
onClick={closeForgotModal}
|
disabled={forgotLoading || !forgotEmail}
|
||||||
className="w-full mt-4 text-gray-400 hover:text-white text-sm transition-colors"
|
className="w-full bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white font-semibold py-4 rounded-xl shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
← Back to login
|
{forgotLoading
|
||||||
</button>
|
? (
|
||||||
</>
|
<>
|
||||||
) : (
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
/* Success State */
|
Sending...
|
||||||
<div className="text-center py-4">
|
</>
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full shadow-lg mb-4">
|
)
|
||||||
<CheckCircle size={32} className="text-white" />
|
: (
|
||||||
|
<>
|
||||||
|
<Mail size={18} />
|
||||||
|
Send Reset Link
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Back to login */}
|
||||||
|
<button
|
||||||
|
onClick={closeForgotModal}
|
||||||
|
className="w-full mt-4 text-gray-400 hover:text-white text-sm transition-colors"
|
||||||
|
>
|
||||||
|
← Back to login
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
/* Success State */
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full shadow-lg mb-4">
|
||||||
|
<CheckCircle size={32} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">
|
||||||
|
Check Your Email
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-6">
|
||||||
|
We've sent password reset instructions to<br />
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{forgotEmail}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={closeForgotModal}
|
||||||
|
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-semibold py-4 rounded-xl shadow-lg transition-all duration-300"
|
||||||
|
>
|
||||||
|
Back to Login
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-white mb-2">Check Your Email</h2>
|
)}
|
||||||
<p className="text-gray-400 text-sm mb-6">
|
|
||||||
We've sent password reset instructions to<br />
|
|
||||||
<span className="text-white font-medium">{forgotEmail}</span>
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={closeForgotModal}
|
|
||||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-semibold py-4 rounded-xl shadow-lg transition-all duration-300"
|
|
||||||
>
|
|
||||||
Back to Login
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CSS for floating animation */}
|
{/* CSS for floating animation */}
|
||||||
<style>{`
|
<style>
|
||||||
|
{`
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0px) rotate(0deg); opacity: 0.2; }
|
0%, 100% { transform: translateY(0px) rotate(0deg); opacity: 0.2; }
|
||||||
50% { transform: translateY(-20px) rotate(180deg); opacity: 0.5; }
|
50% { transform: translateY(-20px) rotate(180deg); opacity: 0.5; }
|
||||||
@@ -341,7 +381,8 @@ export const LoginPage: React.FC = () => {
|
|||||||
.animate-float {
|
.animate-float {
|
||||||
animation: float linear infinite;
|
animation: float linear infinite;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}
|
||||||
|
</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,43 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Plus, RefreshCw, Trash2, Edit, DollarSign, Search } from 'lucide-react';
|
import { DollarSign, Edit, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||||
import { Card, CardHeader, CardContent } from '../components/ui/Card';
|
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
import {
|
||||||
import { Button } from '../components/ui/Button';
|
Table,
|
||||||
import { Input, Select } from '../components/ui/Input';
|
TableBody,
|
||||||
import { api } from '../services/api';
|
TableCell,
|
||||||
import { useDepartments, useSubDepartments } from '../hooks/useDepartments';
|
TableHead,
|
||||||
import { useActivities } from '../hooks/useActivities';
|
TableHeader,
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
TableRow,
|
||||||
|
} from "../components/ui/Table.tsx";
|
||||||
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
|
import { Input, Select } from "../components/ui/Input.tsx";
|
||||||
|
import { api } from "../services/api.ts";
|
||||||
|
import { useDepartments, useSubDepartments } from "../hooks/useDepartments.ts";
|
||||||
|
import { useActivities } from "../hooks/useActivities.ts";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
|
||||||
export const RatesPage: React.FC = () => {
|
export const RatesPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'list' | 'add'>('list');
|
const [activeTab, setActiveTab] = useState<"list" | "add">("list");
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { departments } = useDepartments();
|
const { departments } = useDepartments();
|
||||||
const [rates, setRates] = useState<any[]>([]);
|
const [rates, setRates] = useState<any[]>([]);
|
||||||
const [contractors, setContractors] = useState<any[]>([]);
|
const [contractors, setContractors] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
contractorId: '',
|
contractorId: "",
|
||||||
subDepartmentId: '',
|
subDepartmentId: "",
|
||||||
activity: '',
|
activity: "",
|
||||||
rate: '',
|
rate: "",
|
||||||
effectiveDate: new Date().toISOString().split('T')[0],
|
effectiveDate: new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
const [selectedDept, setSelectedDept] = useState('');
|
const [selectedDept, setSelectedDept] = useState("");
|
||||||
const { subDepartments } = useSubDepartments(selectedDept);
|
const { subDepartments } = useSubDepartments(selectedDept);
|
||||||
const { activities } = useActivities(formData.subDepartmentId);
|
const { activities } = useActivities(formData.subDepartmentId);
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState("");
|
||||||
const [formLoading, setFormLoading] = useState(false);
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Edit mode
|
// Edit mode
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
@@ -38,12 +45,12 @@ export const RatesPage: React.FC = () => {
|
|||||||
// Fetch rates
|
// Fetch rates
|
||||||
const fetchRates = async () => {
|
const fetchRates = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
const data = await api.getContractorRates();
|
const data = await api.getContractorRates();
|
||||||
setRates(data);
|
setRates(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch rates');
|
setError(err.message || "Failed to fetch rates");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -52,10 +59,10 @@ export const RatesPage: React.FC = () => {
|
|||||||
// Fetch contractors
|
// Fetch contractors
|
||||||
const fetchContractors = async () => {
|
const fetchContractors = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getUsers({ role: 'Contractor' });
|
const data = await api.getUsers({ role: "Contractor" });
|
||||||
setContractors(data);
|
setContractors(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch contractors:', err);
|
console.error("Failed to fetch contractors:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,54 +73,62 @@ export const RatesPage: React.FC = () => {
|
|||||||
|
|
||||||
// Auto-select department for supervisors
|
// Auto-select department for supervisors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role === 'Supervisor' && user?.department_id) {
|
if (user?.role === "Supervisor" && user?.department_id) {
|
||||||
setSelectedDept(String(user.department_id));
|
setSelectedDept(String(user.department_id));
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleInputChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
||||||
// Auto-select department when contractor is selected
|
// Auto-select department when contractor is selected
|
||||||
if (name === 'contractorId' && value) {
|
if (name === "contractorId" && value) {
|
||||||
const selectedContractor = contractors.find(c => String(c.id) === value);
|
const selectedContractor = contractors.find((c) =>
|
||||||
|
String(c.id) === value
|
||||||
|
);
|
||||||
if (selectedContractor?.department_id) {
|
if (selectedContractor?.department_id) {
|
||||||
setSelectedDept(String(selectedContractor.department_id));
|
setSelectedDept(String(selectedContractor.department_id));
|
||||||
// Clear sub-department and activity when contractor changes
|
// Clear sub-department and activity when contractor changes
|
||||||
setFormData(prev => ({ ...prev, [name]: value, subDepartmentId: '', activity: '' }));
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
subDepartmentId: "",
|
||||||
|
activity: "",
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
}
|
}
|
||||||
}
|
} // Clear activity when sub-department changes
|
||||||
// Clear activity when sub-department changes
|
else if (name === "subDepartmentId") {
|
||||||
else if (name === 'subDepartmentId') {
|
setFormData((prev) => ({ ...prev, [name]: value, activity: "" }));
|
||||||
setFormData(prev => ({ ...prev, [name]: value, activity: '' }));
|
|
||||||
} else {
|
} else {
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
}
|
}
|
||||||
setFormError('');
|
setFormError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
contractorId: '',
|
contractorId: "",
|
||||||
subDepartmentId: '',
|
subDepartmentId: "",
|
||||||
activity: '',
|
activity: "",
|
||||||
rate: '',
|
rate: "",
|
||||||
effectiveDate: new Date().toISOString().split('T')[0],
|
effectiveDate: new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setFormError('');
|
setFormError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.contractorId || !formData.rate || !formData.effectiveDate) {
|
if (!formData.contractorId || !formData.rate || !formData.effectiveDate) {
|
||||||
setFormError('Contractor, rate, and effective date are required');
|
setFormError("Contractor, rate, and effective date are required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormLoading(true);
|
setFormLoading(true);
|
||||||
setFormError('');
|
setFormError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@@ -125,7 +140,9 @@ export const RatesPage: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
await api.setContractorRate({
|
await api.setContractorRate({
|
||||||
contractorId: parseInt(formData.contractorId),
|
contractorId: parseInt(formData.contractorId),
|
||||||
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : undefined,
|
subDepartmentId: formData.subDepartmentId
|
||||||
|
? parseInt(formData.subDepartmentId)
|
||||||
|
: undefined,
|
||||||
activity: formData.activity || undefined,
|
activity: formData.activity || undefined,
|
||||||
rate: parseFloat(formData.rate),
|
rate: parseFloat(formData.rate),
|
||||||
effectiveDate: formData.effectiveDate,
|
effectiveDate: formData.effectiveDate,
|
||||||
@@ -133,10 +150,10 @@ export const RatesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
setActiveTab('list');
|
setActiveTab("list");
|
||||||
fetchRates();
|
fetchRates();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setFormError(err.message || 'Failed to save rate');
|
setFormError(err.message || "Failed to save rate");
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
@@ -145,32 +162,36 @@ export const RatesPage: React.FC = () => {
|
|||||||
const handleEdit = (rate: any) => {
|
const handleEdit = (rate: any) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
contractorId: String(rate.contractor_id),
|
contractorId: String(rate.contractor_id),
|
||||||
subDepartmentId: rate.sub_department_id ? String(rate.sub_department_id) : '',
|
subDepartmentId: rate.sub_department_id
|
||||||
activity: rate.activity || '',
|
? String(rate.sub_department_id)
|
||||||
|
: "",
|
||||||
|
activity: rate.activity || "",
|
||||||
rate: String(rate.rate),
|
rate: String(rate.rate),
|
||||||
effectiveDate: rate.effective_date?.split('T')[0] || new Date().toISOString().split('T')[0],
|
effectiveDate: rate.effective_date?.split("T")[0] ||
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
setEditingId(rate.id);
|
setEditingId(rate.id);
|
||||||
setActiveTab('add');
|
setActiveTab("add");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('Are you sure you want to delete this rate?')) return;
|
if (!confirm("Are you sure you want to delete this rate?")) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteContractorRate(id);
|
await api.deleteContractorRate(id);
|
||||||
fetchRates();
|
fetchRates();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to delete rate');
|
alert(err.message || "Failed to delete rate");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canManageRates = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
|
const canManageRates = user?.role === "SuperAdmin" ||
|
||||||
|
user?.role === "Supervisor";
|
||||||
|
|
||||||
// Filter rates based on search
|
// Filter rates based on search
|
||||||
const filteredRates = useMemo(() => {
|
const filteredRates = useMemo(() => {
|
||||||
if (!searchQuery) return rates;
|
if (!searchQuery) return rates;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return rates.filter(rate =>
|
return rates.filter((rate) =>
|
||||||
rate.contractor_name?.toLowerCase().includes(query) ||
|
rate.contractor_name?.toLowerCase().includes(query) ||
|
||||||
rate.sub_department_name?.toLowerCase().includes(query) ||
|
rate.sub_department_name?.toLowerCase().includes(query) ||
|
||||||
rate.activity?.toLowerCase().includes(query)
|
rate.activity?.toLowerCase().includes(query)
|
||||||
@@ -183,36 +204,42 @@ export const RatesPage: React.FC = () => {
|
|||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<div className="flex space-x-8 px-6">
|
<div className="flex space-x-8 px-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveTab('list'); resetForm(); }}
|
onClick={() => {
|
||||||
|
setActiveTab("list");
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'list'
|
activeTab === "list"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Rate List
|
Rate List
|
||||||
</button>
|
</button>
|
||||||
{canManageRates && (
|
{canManageRates && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('add')}
|
onClick={() => setActiveTab("add")}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'add'
|
activeTab === "add"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{editingId ? 'Edit Rate' : 'Add Rate'}
|
{editingId ? "Edit Rate" : "Add Rate"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeTab === 'list' && (
|
{activeTab === "list" && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div className="relative min-w-[300px] flex-1">
|
<div className="relative min-w-[300px] flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by contractor, sub-department, activity..."
|
placeholder="Search by contractor, sub-department, activity..."
|
||||||
@@ -226,7 +253,7 @@ export const RatesPage: React.FC = () => {
|
|||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 text-sm text-gray-600">
|
<div className="mb-4 text-sm text-gray-600">
|
||||||
Total Rates: {filteredRates.length}
|
Total Rates: {filteredRates.length}
|
||||||
</div>
|
</div>
|
||||||
@@ -237,91 +264,113 @@ export const RatesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading
|
||||||
<div className="text-center py-8">Loading rates...</div>
|
? <div className="text-center py-8">Loading rates...</div>
|
||||||
) : filteredRates.length > 0 ? (
|
: filteredRates.length > 0
|
||||||
<Table>
|
? (
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableHead>Contractor</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Sub-Department</TableHead>
|
<TableHead>Contractor</TableHead>
|
||||||
<TableHead>Activity</TableHead>
|
<TableHead>Sub-Department</TableHead>
|
||||||
<TableHead>Rate Type</TableHead>
|
<TableHead>Activity</TableHead>
|
||||||
<TableHead>Rate (₹)</TableHead>
|
<TableHead>Rate Type</TableHead>
|
||||||
<TableHead>Effective Date</TableHead>
|
<TableHead>Rate (₹)</TableHead>
|
||||||
{canManageRates && <TableHead>Actions</TableHead>}
|
<TableHead>Effective Date</TableHead>
|
||||||
</TableHeader>
|
{canManageRates && <TableHead>Actions</TableHead>}
|
||||||
<TableBody>
|
</TableHeader>
|
||||||
{filteredRates.map((rate) => (
|
<TableBody>
|
||||||
<TableRow key={rate.id}>
|
{filteredRates.map((rate) => (
|
||||||
<TableCell className="font-medium">{rate.contractor_name}</TableCell>
|
<TableRow key={rate.id}>
|
||||||
<TableCell>{rate.sub_department_name || '-'}</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell>
|
{rate.contractor_name}
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
rate.unit_of_measurement === 'Per Bag'
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'bg-gray-100 text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{rate.activity || 'Standard'}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{rate.unit_of_measurement === 'Per Bag'
|
|
||||||
? 'Per Unit'
|
|
||||||
: 'Flat Rate'}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-green-600 font-semibold">₹{rate.rate}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{new Date(rate.effective_date).toLocaleDateString()}</TableCell>
|
|
||||||
{canManageRates && (
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(rate)}
|
|
||||||
className="text-blue-600"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit size={14} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(rate.id)}
|
|
||||||
className="text-red-600"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
<TableCell>
|
||||||
</TableRow>
|
{rate.sub_department_name || "-"}
|
||||||
))}
|
</TableCell>
|
||||||
</TableBody>
|
<TableCell>
|
||||||
</Table>
|
<span
|
||||||
) : (
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
<div className="text-center py-8 text-gray-500">
|
rate.unit_of_measurement === "Per Bag"
|
||||||
{searchQuery ? 'No matching rates found' : 'No rates configured yet. Add one to get started!'}
|
? "bg-blue-100 text-blue-700"
|
||||||
</div>
|
: "bg-gray-100 text-gray-700"
|
||||||
)}
|
}`}
|
||||||
|
>
|
||||||
|
{rate.activity || "Standard"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{rate.unit_of_measurement === "Per Bag"
|
||||||
|
? "Per Unit"
|
||||||
|
: "Flat Rate"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-green-600 font-semibold">
|
||||||
|
₹{rate.rate}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(rate.effective_date).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
{canManageRates && (
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(rate)}
|
||||||
|
className="text-blue-600"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(rate.id)}
|
||||||
|
className="text-red-600"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
{searchQuery
|
||||||
|
? "No matching rates found"
|
||||||
|
: "No rates configured yet. Add one to get started!"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'add' && canManageRates && (
|
{activeTab === "add" && canManageRates && (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-800">
|
<h3 className="text-lg font-semibold text-gray-800">
|
||||||
{editingId ? 'Edit Rate' : 'Add New Rate'}
|
{editingId ? "Edit Rate" : "Add New Rate"}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
<h4 className="font-medium text-blue-800 mb-2">Rate Calculation Info</h4>
|
<h4 className="font-medium text-blue-800 mb-2">
|
||||||
|
Rate Calculation Info
|
||||||
|
</h4>
|
||||||
<ul className="text-sm text-blue-700 space-y-1">
|
<ul className="text-sm text-blue-700 space-y-1">
|
||||||
<li><strong>Per Bag Activities:</strong> Total = Units × Rate per Unit</li>
|
<li>
|
||||||
<li><strong>Fixed Rate Activities:</strong> Total = Flat Rate (no unit calculation)</li>
|
<strong>Per Bag Activities:</strong>{" "}
|
||||||
|
Total = Units × Rate per Unit
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Fixed Rate Activities:</strong>{" "}
|
||||||
|
Total = Flat Rate (no unit calculation)
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,27 +389,37 @@ export const RatesPage: React.FC = () => {
|
|||||||
required
|
required
|
||||||
disabled={!!editingId}
|
disabled={!!editingId}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select Contractor' },
|
{ value: "", label: "Select Contractor" },
|
||||||
...contractors.map(c => ({ value: String(c.id), label: c.name }))
|
...contractors.map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{user?.role === 'Supervisor' ? (
|
{user?.role === "Supervisor"
|
||||||
<Input
|
? (
|
||||||
label="Department"
|
<Input
|
||||||
value={departments.find(d => d.id === user?.department_id)?.name || 'Loading...'}
|
label="Department"
|
||||||
disabled
|
value={departments.find((d) =>
|
||||||
/>
|
d.id === user?.department_id
|
||||||
) : (
|
)?.name || "Loading..."}
|
||||||
<Select
|
disabled
|
||||||
label="Department"
|
/>
|
||||||
value={selectedDept}
|
)
|
||||||
onChange={(e) => setSelectedDept(e.target.value)}
|
: (
|
||||||
options={[
|
<Select
|
||||||
{ value: '', label: 'Select Department' },
|
label="Department"
|
||||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
value={selectedDept}
|
||||||
]}
|
onChange={(e) => setSelectedDept(e.target.value)}
|
||||||
/>
|
options={[
|
||||||
)}
|
{ value: "", label: "Select Department" },
|
||||||
|
...departments.map((d) => ({
|
||||||
|
value: String(d.id),
|
||||||
|
label: d.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Select
|
<Select
|
||||||
label="Sub-Department"
|
label="Sub-Department"
|
||||||
name="subDepartmentId"
|
name="subDepartmentId"
|
||||||
@@ -368,8 +427,11 @@ export const RatesPage: React.FC = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
disabled={!!editingId}
|
disabled={!!editingId}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select Sub-Department (Optional)' },
|
{ value: "", label: "Select Sub-Department (Optional)" },
|
||||||
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
|
...subDepartments.map((s) => ({
|
||||||
|
value: String(s.id),
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -379,18 +441,29 @@ export const RatesPage: React.FC = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
disabled={!formData.subDepartmentId}
|
disabled={!formData.subDepartmentId}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: formData.subDepartmentId ? 'Select Activity (Optional)' : 'Select Sub-Department First' },
|
{
|
||||||
...activities.map(a => ({
|
value: "",
|
||||||
value: a.name,
|
label: formData.subDepartmentId
|
||||||
label: `${a.name} (${a.unit_of_measurement === 'Per Bag' ? 'per unit × rate' : 'flat rate'})`
|
? "Select Activity (Optional)"
|
||||||
}))
|
: "Select Sub-Department First",
|
||||||
|
},
|
||||||
|
...activities.map((a) => ({
|
||||||
|
value: a.name,
|
||||||
|
label: `${a.name} (${
|
||||||
|
a.unit_of_measurement === "Per Bag"
|
||||||
|
? "per unit × rate"
|
||||||
|
: "flat rate"
|
||||||
|
})`,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={(() => {
|
label={(() => {
|
||||||
const selectedActivity = activities.find(a => a.name === formData.activity);
|
const selectedActivity = activities.find((a) =>
|
||||||
return selectedActivity?.unit_of_measurement === 'Per Bag'
|
a.name === formData.activity
|
||||||
? "Rate per Unit (₹)"
|
);
|
||||||
|
return selectedActivity?.unit_of_measurement === "Per Bag"
|
||||||
|
? "Rate per Unit (₹)"
|
||||||
: "Rate Amount (₹)";
|
: "Rate Amount (₹)";
|
||||||
})()}
|
})()}
|
||||||
name="rate"
|
name="rate"
|
||||||
@@ -411,14 +484,20 @@ export const RatesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button variant="outline" onClick={() => { setActiveTab('list'); resetForm(); }}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("list");
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={formLoading}>
|
<Button onClick={handleSubmit} disabled={formLoading}>
|
||||||
{formLoading ? 'Saving...' : (
|
{formLoading ? "Saving..." : (
|
||||||
<>
|
<>
|
||||||
<DollarSign size={16} className="mr-2" />
|
<DollarSign size={16} className="mr-2" />
|
||||||
{editingId ? 'Update Rate' : 'Add Rate'}
|
{editingId ? "Update Rate" : "Add Rate"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,55 +1,104 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Download, RefreshCw, Search, FileSpreadsheet, Filter } from 'lucide-react';
|
import {
|
||||||
import { Card, CardContent } from '../components/ui/Card';
|
Download,
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
FileSpreadsheet,
|
||||||
import { Button } from '../components/ui/Button';
|
Filter,
|
||||||
import { Input, Select } from '../components/ui/Input';
|
RefreshCw,
|
||||||
import { api } from '../services/api';
|
Search,
|
||||||
import { useDepartments } from '../hooks/useDepartments';
|
} from "lucide-react";
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||||
import { exportWorkReportToXLSX, exportAllocationsToXLSX } from '../utils/excelExport';
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../components/ui/Table.tsx";
|
||||||
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
|
import { Input, Select } from "../components/ui/Input.tsx";
|
||||||
|
import { api } from "../services/api.ts";
|
||||||
|
import { useDepartments } from "../hooks/useDepartments.ts";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
import {
|
||||||
|
exportAllocationsToXLSX,
|
||||||
|
exportWorkReportToXLSX,
|
||||||
|
} from "../utils/excelExport.ts";
|
||||||
|
|
||||||
export const ReportingPage: React.FC = () => {
|
export const ReportingPage: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { departments } = useDepartments();
|
const { departments } = useDepartments();
|
||||||
const [allocations, setAllocations] = useState<any[]>([]);
|
const [allocations, setAllocations] = useState<any[]>([]);
|
||||||
const [summary, setSummary] = useState<{ totalAllocations: number; totalAmount: string; totalUnits: string } | null>(null);
|
const [summary, setSummary] = useState<
|
||||||
|
{ totalAllocations: number; totalAmount: string; totalUnits: string } | null
|
||||||
|
>(null);
|
||||||
const [contractors, setContractors] = useState<any[]>([]);
|
const [contractors, setContractors] = useState<any[]>([]);
|
||||||
|
const [employees, setEmployees] = useState<any[]>([]);
|
||||||
|
const [subDepartments, setSubDepartments] = useState<any[]>([]);
|
||||||
|
const [activities, setActivities] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
startDate: '',
|
startDate: "",
|
||||||
endDate: '',
|
endDate: "",
|
||||||
departmentId: '',
|
departmentId: "",
|
||||||
contractorId: '',
|
contractorId: "",
|
||||||
|
employeeId: "",
|
||||||
|
subDepartmentId: "",
|
||||||
|
activity: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSuperAdmin = user?.role === 'SuperAdmin';
|
const isSupervisor = user?.role === "Supervisor";
|
||||||
|
const isContractor = user?.role === "Contractor";
|
||||||
|
|
||||||
// Fetch contractors
|
// Fetch filter options
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getUsers({ role: 'Contractor' }).then(setContractors).catch(console.error);
|
api.getUsers({ role: "Contractor" }).then(setContractors).catch(
|
||||||
|
console.error,
|
||||||
|
);
|
||||||
|
api.getUsers({ role: "Employee" }).then(setEmployees).catch(console.error);
|
||||||
|
api.getAllSubDepartments().then(setSubDepartments).catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch report data
|
// Fetch report data
|
||||||
const fetchReport = async () => {
|
const fetchReport = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (filters.startDate) params.startDate = filters.startDate;
|
if (filters.startDate) params.startDate = filters.startDate;
|
||||||
if (filters.endDate) params.endDate = filters.endDate;
|
if (filters.endDate) params.endDate = filters.endDate;
|
||||||
if (filters.departmentId && isSuperAdmin) params.departmentId = parseInt(filters.departmentId);
|
|
||||||
if (filters.contractorId) params.contractorId = parseInt(filters.contractorId);
|
// Department filter - use user's department if Supervisor, otherwise use filter
|
||||||
|
const deptId = isSupervisor
|
||||||
|
? user?.department_id
|
||||||
|
: (filters.departmentId ? parseInt(filters.departmentId) : null);
|
||||||
|
if (deptId) params.departmentId = deptId;
|
||||||
|
|
||||||
|
// Contractor filter - use user's id if Contractor, otherwise use filter
|
||||||
|
const contractorIdValue = isContractor
|
||||||
|
? user?.id
|
||||||
|
: (filters.contractorId ? parseInt(filters.contractorId) : null);
|
||||||
|
if (contractorIdValue) params.contractorId = contractorIdValue;
|
||||||
|
|
||||||
|
if (filters.employeeId) params.employeeId = parseInt(filters.employeeId);
|
||||||
|
|
||||||
const data = await api.getCompletedAllocationsReport(params);
|
const data = await api.getCompletedAllocationsReport(params);
|
||||||
setAllocations(data.allocations);
|
setAllocations(data.allocations);
|
||||||
setSummary(data.summary);
|
setSummary(data.summary);
|
||||||
|
|
||||||
|
// Extract unique activities from allocations for the filter dropdown
|
||||||
|
const uniqueActivities = [
|
||||||
|
...new Set(
|
||||||
|
data.allocations.map((a: any) => a.activity).filter(Boolean),
|
||||||
|
),
|
||||||
|
] as string[];
|
||||||
|
setActivities(uniqueActivities);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch report');
|
setError(err.message || "Failed to fetch report");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -59,53 +108,75 @@ export const ReportingPage: React.FC = () => {
|
|||||||
fetchReport();
|
fetchReport();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter allocations based on search
|
// Filter allocations based on search and dropdown filters
|
||||||
const filteredAllocations = useMemo(() => {
|
const filteredAllocations = useMemo(() => {
|
||||||
if (!searchQuery) return allocations;
|
let result = allocations;
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return allocations.filter(a =>
|
// Apply search filter
|
||||||
a.employee_name?.toLowerCase().includes(query) ||
|
if (searchQuery) {
|
||||||
a.contractor_name?.toLowerCase().includes(query) ||
|
const query = searchQuery.toLowerCase();
|
||||||
a.sub_department_name?.toLowerCase().includes(query) ||
|
result = result.filter((a) =>
|
||||||
a.activity?.toLowerCase().includes(query) ||
|
a.employee_name?.toLowerCase().includes(query) ||
|
||||||
a.department_name?.toLowerCase().includes(query)
|
a.contractor_name?.toLowerCase().includes(query) ||
|
||||||
);
|
a.sub_department_name?.toLowerCase().includes(query) ||
|
||||||
}, [allocations, searchQuery]);
|
a.activity?.toLowerCase().includes(query) ||
|
||||||
|
a.department_name?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sub-department filter (client-side)
|
||||||
|
if (filters.subDepartmentId) {
|
||||||
|
result = result.filter((a) =>
|
||||||
|
a.sub_department_id === parseInt(filters.subDepartmentId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply activity filter (client-side)
|
||||||
|
if (filters.activity) {
|
||||||
|
result = result.filter((a) => a.activity === filters.activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [allocations, searchQuery, filters.subDepartmentId, filters.activity]);
|
||||||
|
|
||||||
// Get selected department name
|
// Get selected department name
|
||||||
const selectedDeptName = filters.departmentId
|
const selectedDeptName = filters.departmentId
|
||||||
? departments.find(d => d.id === parseInt(filters.departmentId))?.name || 'All Departments'
|
? departments.find((d) => d.id === parseInt(filters.departmentId))?.name ||
|
||||||
: user?.role === 'Supervisor'
|
"All Departments"
|
||||||
? departments.find(d => d.id === user?.department_id)?.name || 'Department'
|
: user?.role === "Supervisor"
|
||||||
: 'All Departments';
|
? departments.find((d) => d.id === user?.department_id)?.name ||
|
||||||
|
"Department"
|
||||||
|
: "All Departments";
|
||||||
|
|
||||||
// Export to Excel (XLSX format) - Formatted Report
|
// Export to Excel (XLSX format) - Formatted Report
|
||||||
const exportFormattedReport = () => {
|
const exportFormattedReport = () => {
|
||||||
if (filteredAllocations.length === 0) {
|
if (filteredAllocations.length === 0) {
|
||||||
alert('No data to export');
|
alert("No data to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
exportWorkReportToXLSX(
|
exportWorkReportToXLSX(
|
||||||
filteredAllocations,
|
filteredAllocations,
|
||||||
selectedDeptName,
|
selectedDeptName,
|
||||||
{ startDate: filters.startDate, endDate: filters.endDate }
|
{ startDate: filters.startDate, endDate: filters.endDate },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export to Excel (XLSX format) - Simple List
|
// Export to Excel (XLSX format) - Simple List
|
||||||
const exportSimpleList = () => {
|
const exportSimpleList = () => {
|
||||||
if (filteredAllocations.length === 0) {
|
if (filteredAllocations.length === 0) {
|
||||||
alert('No data to export');
|
alert("No data to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
exportAllocationsToXLSX(filteredAllocations);
|
exportAllocationsToXLSX(filteredAllocations);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleFilterChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFilters(prev => ({ ...prev, [name]: value }));
|
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
@@ -114,10 +185,13 @@ export const ReportingPage: React.FC = () => {
|
|||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setFilters({
|
setFilters({
|
||||||
startDate: '',
|
startDate: "",
|
||||||
endDate: '',
|
endDate: "",
|
||||||
departmentId: '',
|
departmentId: "",
|
||||||
contractorId: '',
|
contractorId: "",
|
||||||
|
employeeId: "",
|
||||||
|
subDepartmentId: "",
|
||||||
|
activity: "",
|
||||||
});
|
});
|
||||||
setTimeout(fetchReport, 0);
|
setTimeout(fetchReport, 0);
|
||||||
};
|
};
|
||||||
@@ -129,14 +203,23 @@ export const ReportingPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileSpreadsheet className="text-green-600" size={24} />
|
<FileSpreadsheet className="text-green-600" size={24} />
|
||||||
<h2 className="text-xl font-semibold text-gray-800">Work Allocation Reports</h2>
|
<h2 className="text-xl font-semibold text-gray-800">
|
||||||
|
Work Allocation Reports
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={exportFormattedReport} disabled={filteredAllocations.length === 0}>
|
<Button
|
||||||
|
onClick={exportFormattedReport}
|
||||||
|
disabled={filteredAllocations.length === 0}
|
||||||
|
>
|
||||||
<Download size={16} className="mr-2" />
|
<Download size={16} className="mr-2" />
|
||||||
Export Report (XLSX)
|
Export Report (XLSX)
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={exportSimpleList} disabled={filteredAllocations.length === 0}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={exportSimpleList}
|
||||||
|
disabled={filteredAllocations.length === 0}
|
||||||
|
>
|
||||||
<Download size={16} className="mr-2" />
|
<Download size={16} className="mr-2" />
|
||||||
Export List
|
Export List
|
||||||
</Button>
|
</Button>
|
||||||
@@ -153,7 +236,9 @@ export const ReportingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
name="startDate"
|
name="startDate"
|
||||||
@@ -162,7 +247,9 @@ export const ReportingPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
name="endDate"
|
name="endDate"
|
||||||
@@ -170,26 +257,74 @@ export const ReportingPage: React.FC = () => {
|
|||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isSuperAdmin && (
|
<Select
|
||||||
<Select
|
label="Department"
|
||||||
label="Department"
|
name="departmentId"
|
||||||
name="departmentId"
|
value={isSupervisor
|
||||||
value={filters.departmentId}
|
? String(user?.department_id || "")
|
||||||
onChange={handleFilterChange}
|
: filters.departmentId}
|
||||||
options={[
|
onChange={handleFilterChange}
|
||||||
{ value: '', label: 'All Departments' },
|
disabled={isSupervisor}
|
||||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
options={[
|
||||||
]}
|
{ value: "", label: "All Departments" },
|
||||||
/>
|
...departments.map((d) => ({
|
||||||
)}
|
value: String(d.id),
|
||||||
|
label: d.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Contractor"
|
label="Contractor"
|
||||||
name="contractorId"
|
name="contractorId"
|
||||||
value={filters.contractorId}
|
value={isContractor
|
||||||
|
? String(user?.id || "")
|
||||||
|
: filters.contractorId}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
disabled={isContractor}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All Contractors" },
|
||||||
|
...contractors.map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<Select
|
||||||
|
label="Employee"
|
||||||
|
name="employeeId"
|
||||||
|
value={filters.employeeId}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'All Contractors' },
|
{ value: "", label: "All Employees" },
|
||||||
...contractors.map(c => ({ value: String(c.id), label: c.name }))
|
...employees.map((e) => ({
|
||||||
|
value: String(e.id),
|
||||||
|
label: e.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Sub-Department"
|
||||||
|
name="subDepartmentId"
|
||||||
|
value={filters.subDepartmentId}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All Sub-Departments" },
|
||||||
|
...subDepartments.map((sd) => ({
|
||||||
|
value: String(sd.id),
|
||||||
|
label: sd.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Activity"
|
||||||
|
name="activity"
|
||||||
|
value={filters.activity}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All Activities" },
|
||||||
|
...activities.map((a) => ({ value: a, label: a })),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,16 +342,28 @@ export const ReportingPage: React.FC = () => {
|
|||||||
{summary && (
|
{summary && (
|
||||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<div className="text-sm text-blue-600 font-medium">Total Completed</div>
|
<div className="text-sm text-blue-600 font-medium">
|
||||||
<div className="text-2xl font-bold text-blue-800">{summary.totalAllocations}</div>
|
Total Completed
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-800">
|
||||||
|
{summary.totalAllocations}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="text-sm text-green-600 font-medium">Total Amount</div>
|
<div className="text-sm text-green-600 font-medium">
|
||||||
<div className="text-2xl font-bold text-green-800">₹{parseFloat(summary.totalAmount).toLocaleString()}</div>
|
Total Amount
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-800">
|
||||||
|
₹{parseFloat(summary.totalAmount).toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||||
<div className="text-sm text-purple-600 font-medium">Total Units</div>
|
<div className="text-sm text-purple-600 font-medium">
|
||||||
<div className="text-2xl font-bold text-purple-800">{parseFloat(summary.totalUnits).toLocaleString()}</div>
|
Total Units
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-purple-800">
|
||||||
|
{parseFloat(summary.totalUnits).toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -224,7 +371,10 @@ export const ReportingPage: React.FC = () => {
|
|||||||
{/* Search and Refresh */}
|
{/* Search and Refresh */}
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by employee, contractor, department..."
|
placeholder="Search by employee, contractor, department..."
|
||||||
@@ -247,66 +397,86 @@ export const ReportingPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{loading ? (
|
{loading
|
||||||
<div className="text-center py-8">Loading report data...</div>
|
? <div className="text-center py-8">Loading report data...</div>
|
||||||
) : filteredAllocations.length > 0 ? (
|
: filteredAllocations.length > 0
|
||||||
<div className="overflow-x-auto">
|
? (
|
||||||
<Table>
|
<div className="overflow-x-auto">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableHead>ID</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Employee</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Contractor</TableHead>
|
<TableHead>Employee</TableHead>
|
||||||
<TableHead>Department</TableHead>
|
<TableHead>Contractor</TableHead>
|
||||||
<TableHead>Sub-Department</TableHead>
|
<TableHead>Department</TableHead>
|
||||||
<TableHead>Activity</TableHead>
|
<TableHead>Sub-Department</TableHead>
|
||||||
<TableHead>Assigned</TableHead>
|
<TableHead>Activity</TableHead>
|
||||||
<TableHead>Completed</TableHead>
|
<TableHead>Assigned</TableHead>
|
||||||
<TableHead>Rate (₹)</TableHead>
|
<TableHead>Completed</TableHead>
|
||||||
<TableHead>Units</TableHead>
|
<TableHead>Rate (₹)</TableHead>
|
||||||
<TableHead>Total (₹)</TableHead>
|
<TableHead>Units</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Total (₹)</TableHead>
|
||||||
<TableBody>
|
</TableHeader>
|
||||||
{filteredAllocations.map((allocation) => {
|
<TableBody>
|
||||||
const rate = parseFloat(allocation.rate) || 0;
|
{filteredAllocations.map((allocation) => {
|
||||||
const units = parseFloat(allocation.units) || 0;
|
const rate = parseFloat(allocation.rate) || 0;
|
||||||
const total = parseFloat(allocation.total_amount) || rate;
|
const units = parseFloat(allocation.units) || 0;
|
||||||
|
const total = parseFloat(allocation.total_amount) || rate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={allocation.id}>
|
<TableRow key={allocation.id}>
|
||||||
<TableCell>{allocation.id}</TableCell>
|
<TableCell>{allocation.id}</TableCell>
|
||||||
<TableCell className="font-medium">{allocation.employee_name || '-'}</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell>{allocation.contractor_name || '-'}</TableCell>
|
{allocation.employee_name || "-"}
|
||||||
<TableCell>{allocation.department_name || '-'}</TableCell>
|
</TableCell>
|
||||||
<TableCell>{allocation.sub_department_name || '-'}</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
{allocation.contractor_name || "-"}
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
</TableCell>
|
||||||
allocation.activity === 'Loading' || allocation.activity === 'Unloading'
|
<TableCell>
|
||||||
? 'bg-purple-100 text-purple-700'
|
{allocation.department_name || "-"}
|
||||||
: 'bg-gray-100 text-gray-700'
|
</TableCell>
|
||||||
}`}>
|
<TableCell>
|
||||||
{allocation.activity || 'Standard'}
|
{allocation.sub_department_name || "-"}
|
||||||
</span>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>{new Date(allocation.assigned_date).toLocaleDateString()}</TableCell>
|
<span
|
||||||
<TableCell>
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
{allocation.completion_date
|
allocation.activity === "Loading" ||
|
||||||
? new Date(allocation.completion_date).toLocaleDateString()
|
allocation.activity === "Unloading"
|
||||||
: '-'}
|
? "bg-purple-100 text-purple-700"
|
||||||
</TableCell>
|
: "bg-gray-100 text-gray-700"
|
||||||
<TableCell>₹{rate.toFixed(2)}</TableCell>
|
}`}
|
||||||
<TableCell>{units > 0 ? units : '-'}</TableCell>
|
>
|
||||||
<TableCell className="font-semibold text-green-600">₹{total.toFixed(2)}</TableCell>
|
{allocation.activity || "Standard"}
|
||||||
</TableRow>
|
</span>
|
||||||
);
|
</TableCell>
|
||||||
})}
|
<TableCell>
|
||||||
</TableBody>
|
{new Date(allocation.assigned_date)
|
||||||
</Table>
|
.toLocaleDateString()}
|
||||||
</div>
|
</TableCell>
|
||||||
) : (
|
<TableCell>
|
||||||
<div className="text-center py-8 text-gray-500">
|
{allocation.completion_date
|
||||||
No completed work allocations found. Adjust your filters or check back later.
|
? new Date(allocation.completion_date)
|
||||||
</div>
|
.toLocaleDateString()
|
||||||
)}
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>₹{rate.toFixed(2)}</TableCell>
|
||||||
|
<TableCell>{units > 0 ? units : "-"}</TableCell>
|
||||||
|
<TableCell className="font-semibold text-green-600">
|
||||||
|
₹{total.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
No completed work allocations found. Adjust your filters or
|
||||||
|
check back later.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,54 +1,72 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshCw, Trash2, Edit, DollarSign, Search, Scale, ArrowUpDown } from 'lucide-react';
|
import {
|
||||||
import { Card, CardContent } from '../components/ui/Card';
|
ArrowUpDown,
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
DollarSign,
|
||||||
import { Button } from '../components/ui/Button';
|
Edit,
|
||||||
import { Input, Select } from '../components/ui/Input';
|
RefreshCw,
|
||||||
import { api } from '../services/api';
|
Scale,
|
||||||
import { useDepartments, useSubDepartments } from '../hooks/useDepartments';
|
Search,
|
||||||
import { useActivities } from '../hooks/useActivities';
|
Trash2,
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../components/ui/Table.tsx";
|
||||||
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
|
import { Input, Select } from "../components/ui/Input.tsx";
|
||||||
|
import { api } from "../services/api.ts";
|
||||||
|
import { useDepartments, useSubDepartments } from "../hooks/useDepartments.ts";
|
||||||
|
import { useActivities } from "../hooks/useActivities.ts";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
|
||||||
export const StandardRatesPage: React.FC = () => {
|
export const StandardRatesPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'list' | 'add' | 'compare'>('list');
|
const [activeTab, setActiveTab] = useState<"list" | "add" | "compare">(
|
||||||
|
"list",
|
||||||
|
);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { departments } = useDepartments();
|
const { departments } = useDepartments();
|
||||||
const [standardRates, setStandardRates] = useState<any[]>([]);
|
const [standardRates, setStandardRates] = useState<any[]>([]);
|
||||||
const [contractors, setContractors] = useState<any[]>([]);
|
const [contractors, setContractors] = useState<any[]>([]);
|
||||||
const [comparisons, setComparisons] = useState<any[]>([]);
|
const [comparisons, setComparisons] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
subDepartmentId: '',
|
subDepartmentId: "",
|
||||||
activity: '',
|
activity: "",
|
||||||
rate: '',
|
rate: "",
|
||||||
effectiveDate: new Date().toISOString().split('T')[0],
|
effectiveDate: new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
const [selectedDept, setSelectedDept] = useState('');
|
const [selectedDept, setSelectedDept] = useState("");
|
||||||
const { subDepartments } = useSubDepartments(selectedDept);
|
const { subDepartments } = useSubDepartments(selectedDept);
|
||||||
const { activities } = useActivities(formData.subDepartmentId);
|
const { activities } = useActivities(formData.subDepartmentId);
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState("");
|
||||||
const [formLoading, setFormLoading] = useState(false);
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Compare filters
|
// Compare filters
|
||||||
const [compareContractorId, setCompareContractorId] = useState('');
|
const [compareContractorId, setCompareContractorId] = useState("");
|
||||||
|
|
||||||
const isSupervisor = user?.role === 'Supervisor';
|
const isSupervisor = user?.role === "Supervisor";
|
||||||
const canManageRates = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
|
const canManageRates = user?.role === "SuperAdmin" ||
|
||||||
|
user?.role === "Supervisor";
|
||||||
|
|
||||||
// Fetch standard rates
|
// Fetch standard rates
|
||||||
const fetchStandardRates = async () => {
|
const fetchStandardRates = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
const data = await api.getStandardRates();
|
const data = await api.getStandardRates();
|
||||||
setStandardRates(data);
|
setStandardRates(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch standard rates');
|
setError(err.message || "Failed to fetch standard rates");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -57,10 +75,10 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
// Fetch contractors
|
// Fetch contractors
|
||||||
const fetchContractors = async () => {
|
const fetchContractors = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getUsers({ role: 'Contractor' });
|
const data = await api.getUsers({ role: "Contractor" });
|
||||||
setContractors(data);
|
setContractors(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch contractors:', err);
|
console.error("Failed to fetch contractors:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,10 +90,12 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.compareRates({ contractorId: parseInt(compareContractorId) });
|
const data = await api.compareRates({
|
||||||
|
contractorId: parseInt(compareContractorId),
|
||||||
|
});
|
||||||
setComparisons(data.comparisons);
|
setComparisons(data.comparisons);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch comparison');
|
setError(err.message || "Failed to fetch comparison");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -93,41 +113,43 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
}, [isSupervisor, user?.department_id]);
|
}, [isSupervisor, user?.department_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'compare' && compareContractorId) {
|
if (activeTab === "compare" && compareContractorId) {
|
||||||
fetchComparison();
|
fetchComparison();
|
||||||
}
|
}
|
||||||
}, [activeTab, compareContractorId]);
|
}, [activeTab, compareContractorId]);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleInputChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
// Clear activity when sub-department changes
|
// Clear activity when sub-department changes
|
||||||
if (name === 'subDepartmentId') {
|
if (name === "subDepartmentId") {
|
||||||
setFormData(prev => ({ ...prev, [name]: value, activity: '' }));
|
setFormData((prev) => ({ ...prev, [name]: value, activity: "" }));
|
||||||
} else {
|
} else {
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
}
|
}
|
||||||
setFormError('');
|
setFormError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
subDepartmentId: '',
|
subDepartmentId: "",
|
||||||
activity: '',
|
activity: "",
|
||||||
rate: '',
|
rate: "",
|
||||||
effectiveDate: new Date().toISOString().split('T')[0],
|
effectiveDate: new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setFormError('');
|
setFormError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.rate || !formData.effectiveDate) {
|
if (!formData.rate || !formData.effectiveDate) {
|
||||||
setFormError('Rate and effective date are required');
|
setFormError("Rate and effective date are required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormLoading(true);
|
setFormLoading(true);
|
||||||
setFormError('');
|
setFormError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@@ -138,7 +160,9 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await api.createStandardRate({
|
await api.createStandardRate({
|
||||||
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : undefined,
|
subDepartmentId: formData.subDepartmentId
|
||||||
|
? parseInt(formData.subDepartmentId)
|
||||||
|
: undefined,
|
||||||
activity: formData.activity || undefined,
|
activity: formData.activity || undefined,
|
||||||
rate: parseFloat(formData.rate),
|
rate: parseFloat(formData.rate),
|
||||||
effectiveDate: formData.effectiveDate,
|
effectiveDate: formData.effectiveDate,
|
||||||
@@ -146,10 +170,10 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
setActiveTab('list');
|
setActiveTab("list");
|
||||||
fetchStandardRates();
|
fetchStandardRates();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setFormError(err.message || 'Failed to save rate');
|
setFormError(err.message || "Failed to save rate");
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
@@ -157,25 +181,28 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleEdit = (rate: any) => {
|
const handleEdit = (rate: any) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
subDepartmentId: rate.sub_department_id ? String(rate.sub_department_id) : '',
|
subDepartmentId: rate.sub_department_id
|
||||||
activity: rate.activity || '',
|
? String(rate.sub_department_id)
|
||||||
|
: "",
|
||||||
|
activity: rate.activity || "",
|
||||||
rate: String(rate.rate),
|
rate: String(rate.rate),
|
||||||
effectiveDate: rate.effective_date?.split('T')[0] || new Date().toISOString().split('T')[0],
|
effectiveDate: rate.effective_date?.split("T")[0] ||
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
});
|
});
|
||||||
if (rate.department_id) {
|
if (rate.department_id) {
|
||||||
setSelectedDept(String(rate.department_id));
|
setSelectedDept(String(rate.department_id));
|
||||||
}
|
}
|
||||||
setEditingId(rate.id);
|
setEditingId(rate.id);
|
||||||
setActiveTab('add');
|
setActiveTab("add");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('Are you sure you want to delete this standard rate?')) return;
|
if (!confirm("Are you sure you want to delete this standard rate?")) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteStandardRate(id);
|
await api.deleteStandardRate(id);
|
||||||
fetchStandardRates();
|
fetchStandardRates();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to delete rate');
|
alert(err.message || "Failed to delete rate");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,7 +210,7 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
const filteredRates = useMemo(() => {
|
const filteredRates = useMemo(() => {
|
||||||
if (!searchQuery) return standardRates;
|
if (!searchQuery) return standardRates;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return standardRates.filter(rate =>
|
return standardRates.filter((rate) =>
|
||||||
rate.sub_department_name?.toLowerCase().includes(query) ||
|
rate.sub_department_name?.toLowerCase().includes(query) ||
|
||||||
rate.department_name?.toLowerCase().includes(query) ||
|
rate.department_name?.toLowerCase().includes(query) ||
|
||||||
rate.activity?.toLowerCase().includes(query) ||
|
rate.activity?.toLowerCase().includes(query) ||
|
||||||
@@ -197,33 +224,36 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<div className="flex space-x-8 px-6">
|
<div className="flex space-x-8 px-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveTab('list'); resetForm(); }}
|
onClick={() => {
|
||||||
|
setActiveTab("list");
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'list'
|
activeTab === "list"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Standard Rates
|
Standard Rates
|
||||||
</button>
|
</button>
|
||||||
{canManageRates && (
|
{canManageRates && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('add')}
|
onClick={() => setActiveTab("add")}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'add'
|
activeTab === "add"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{editingId ? 'Edit Rate' : 'Add Standard Rate'}
|
{editingId ? "Edit Rate" : "Add Standard Rate"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('compare')}
|
onClick={() => setActiveTab("compare")}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'compare'
|
activeTab === "compare"
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Scale size={16} className="inline mr-1" />
|
<Scale size={16} className="inline mr-1" />
|
||||||
@@ -233,11 +263,14 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeTab === 'list' && (
|
{activeTab === "list" && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div className="relative min-w-[300px] flex-1">
|
<div className="relative min-w-[300px] flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by sub-department, activity..."
|
placeholder="Search by sub-department, activity..."
|
||||||
@@ -254,8 +287,10 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
<p className="text-sm text-blue-700">
|
<p className="text-sm text-blue-700">
|
||||||
<strong>Standard Rates</strong> are default rates set by supervisors for sub-departments and activities.
|
<strong>Standard Rates</strong>{" "}
|
||||||
These are used as benchmarks to compare against contractor-specific rates.
|
are default rates set by supervisors for sub-departments and
|
||||||
|
activities. These are used as benchmarks to compare against
|
||||||
|
contractor-specific rates.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -265,85 +300,104 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading
|
||||||
<div className="text-center py-8">Loading standard rates...</div>
|
? (
|
||||||
) : filteredRates.length > 0 ? (
|
<div className="text-center py-8">
|
||||||
<Table>
|
Loading standard rates...
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableHead>Department</TableHead>
|
)
|
||||||
<TableHead>Sub-Department</TableHead>
|
: filteredRates.length > 0
|
||||||
<TableHead>Activity</TableHead>
|
? (
|
||||||
<TableHead>Rate (₹)</TableHead>
|
<Table>
|
||||||
<TableHead>Effective Date</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Created By</TableHead>
|
<TableHead>Department</TableHead>
|
||||||
{canManageRates && <TableHead>Actions</TableHead>}
|
<TableHead>Sub-Department</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Activity</TableHead>
|
||||||
<TableBody>
|
<TableHead>Rate (₹)</TableHead>
|
||||||
{filteredRates.map((rate) => (
|
<TableHead>Effective Date</TableHead>
|
||||||
<TableRow key={rate.id}>
|
<TableHead>Created By</TableHead>
|
||||||
<TableCell>{rate.department_name || '-'}</TableCell>
|
{canManageRates && <TableHead>Actions</TableHead>}
|
||||||
<TableCell className="font-medium">{rate.sub_department_name || 'All'}</TableCell>
|
</TableHeader>
|
||||||
<TableCell>
|
<TableBody>
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
{filteredRates.map((rate) => (
|
||||||
rate.unit_of_measurement === 'Per Bag'
|
<TableRow key={rate.id}>
|
||||||
? 'bg-blue-100 text-blue-700'
|
<TableCell>{rate.department_name || "-"}</TableCell>
|
||||||
: 'bg-gray-100 text-gray-700'
|
<TableCell className="font-medium">
|
||||||
}`}>
|
{rate.sub_department_name || "All"}
|
||||||
{rate.activity || 'Standard'}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-green-600 font-semibold">₹{rate.rate}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{new Date(rate.effective_date).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell className="text-gray-500">{rate.created_by_name || '-'}</TableCell>
|
|
||||||
{canManageRates && (
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(rate)}
|
|
||||||
className="text-blue-600"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit size={14} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(rate.id)}
|
|
||||||
className="text-red-600"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
<TableCell>
|
||||||
</TableRow>
|
<span
|
||||||
))}
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
</TableBody>
|
rate.unit_of_measurement === "Per Bag"
|
||||||
</Table>
|
? "bg-blue-100 text-blue-700"
|
||||||
) : (
|
: "bg-gray-100 text-gray-700"
|
||||||
<div className="text-center py-8 text-gray-500">
|
}`}
|
||||||
No standard rates configured yet. Add one to get started!
|
>
|
||||||
</div>
|
{rate.activity || "Standard"}
|
||||||
)}
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-green-600 font-semibold">
|
||||||
|
₹{rate.rate}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(rate.effective_date).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500">
|
||||||
|
{rate.created_by_name || "-"}
|
||||||
|
</TableCell>
|
||||||
|
{canManageRates && (
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(rate)}
|
||||||
|
className="text-blue-600"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(rate.id)}
|
||||||
|
className="text-red-600"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
No standard rates configured yet. Add one to get started!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'add' && canManageRates && (
|
{activeTab === "add" && canManageRates && (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-800">
|
<h3 className="text-lg font-semibold text-gray-800">
|
||||||
{editingId ? 'Edit Standard Rate' : 'Add New Standard Rate'}
|
{editingId ? "Edit Standard Rate" : "Add New Standard Rate"}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||||
<h4 className="font-medium text-yellow-800 mb-2">About Standard Rates</h4>
|
<h4 className="font-medium text-yellow-800 mb-2">
|
||||||
|
About Standard Rates
|
||||||
|
</h4>
|
||||||
<p className="text-sm text-yellow-700">
|
<p className="text-sm text-yellow-700">
|
||||||
Standard rates serve as default benchmarks for sub-departments and activities.
|
Standard rates serve as default benchmarks for sub-departments
|
||||||
Contractor rates can be compared against these to identify deviations.
|
and activities. Contractor rates can be compared against these
|
||||||
|
to identify deviations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,23 +408,30 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
{isSupervisor ? (
|
{isSupervisor
|
||||||
<Input
|
? (
|
||||||
label="Department"
|
<Input
|
||||||
value={departments.find(d => d.id === user?.department_id)?.name || 'Loading...'}
|
label="Department"
|
||||||
disabled
|
value={departments.find((d) =>
|
||||||
/>
|
d.id === user?.department_id
|
||||||
) : (
|
)?.name || "Loading..."}
|
||||||
<Select
|
disabled
|
||||||
label="Department"
|
/>
|
||||||
value={selectedDept}
|
)
|
||||||
onChange={(e) => setSelectedDept(e.target.value)}
|
: (
|
||||||
options={[
|
<Select
|
||||||
{ value: '', label: 'Select Department' },
|
label="Department"
|
||||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
value={selectedDept}
|
||||||
]}
|
onChange={(e) => setSelectedDept(e.target.value)}
|
||||||
/>
|
options={[
|
||||||
)}
|
{ value: "", label: "Select Department" },
|
||||||
|
...departments.map((d) => ({
|
||||||
|
value: String(d.id),
|
||||||
|
label: d.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Select
|
<Select
|
||||||
label="Sub-Department"
|
label="Sub-Department"
|
||||||
name="subDepartmentId"
|
name="subDepartmentId"
|
||||||
@@ -378,8 +439,11 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
disabled={!!editingId}
|
disabled={!!editingId}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'All Sub-Departments' },
|
{ value: "", label: "All Sub-Departments" },
|
||||||
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
|
...subDepartments.map((s) => ({
|
||||||
|
value: String(s.id),
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -389,18 +453,29 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
disabled={!formData.subDepartmentId}
|
disabled={!formData.subDepartmentId}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: formData.subDepartmentId ? 'Standard (Default)' : 'Select Sub-Department First' },
|
{
|
||||||
...activities.map(a => ({
|
value: "",
|
||||||
value: a.name,
|
label: formData.subDepartmentId
|
||||||
label: `${a.name} (${a.unit_of_measurement === 'Per Bag' ? 'per unit' : 'flat rate'})`
|
? "Standard (Default)"
|
||||||
}))
|
: "Select Sub-Department First",
|
||||||
|
},
|
||||||
|
...activities.map((a) => ({
|
||||||
|
value: a.name,
|
||||||
|
label: `${a.name} (${
|
||||||
|
a.unit_of_measurement === "Per Bag"
|
||||||
|
? "per unit"
|
||||||
|
: "flat rate"
|
||||||
|
})`,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={(() => {
|
label={(() => {
|
||||||
const selectedActivity = activities.find(a => a.name === formData.activity);
|
const selectedActivity = activities.find((a) =>
|
||||||
return selectedActivity?.unit_of_measurement === 'Per Bag'
|
a.name === formData.activity
|
||||||
? "Rate per Unit (₹)"
|
);
|
||||||
|
return selectedActivity?.unit_of_measurement === "Per Bag"
|
||||||
|
? "Rate per Unit (₹)"
|
||||||
: "Standard Rate (₹)";
|
: "Standard Rate (₹)";
|
||||||
})()}
|
})()}
|
||||||
name="rate"
|
name="rate"
|
||||||
@@ -421,14 +496,20 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button variant="outline" onClick={() => { setActiveTab('list'); resetForm(); }}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("list");
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={formLoading}>
|
<Button onClick={handleSubmit} disabled={formLoading}>
|
||||||
{formLoading ? 'Saving...' : (
|
{formLoading ? "Saving..." : (
|
||||||
<>
|
<>
|
||||||
<DollarSign size={16} className="mr-2" />
|
<DollarSign size={16} className="mr-2" />
|
||||||
{editingId ? 'Update Rate' : 'Add Standard Rate'}
|
{editingId ? "Update Rate" : "Add Standard Rate"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -436,7 +517,7 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'compare' && (
|
{activeTab === "compare" && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
@@ -451,81 +532,109 @@ export const StandardRatesPage: React.FC = () => {
|
|||||||
value={compareContractorId}
|
value={compareContractorId}
|
||||||
onChange={(e) => setCompareContractorId(e.target.value)}
|
onChange={(e) => setCompareContractorId(e.target.value)}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select Contractor' },
|
{ value: "", label: "Select Contractor" },
|
||||||
...contractors.map(c => ({ value: String(c.id), label: c.name }))
|
...contractors.map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={fetchComparison} disabled={!compareContractorId}>
|
<Button
|
||||||
|
onClick={fetchComparison}
|
||||||
|
disabled={!compareContractorId}
|
||||||
|
>
|
||||||
Compare
|
Compare
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading
|
||||||
<div className="text-center py-8">Loading comparison...</div>
|
? <div className="text-center py-8">Loading comparison...</div>
|
||||||
) : comparisons.length > 0 ? (
|
: comparisons.length > 0
|
||||||
<Table>
|
? (
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableHead>Sub-Department</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Activity</TableHead>
|
<TableHead>Sub-Department</TableHead>
|
||||||
<TableHead>Contractor Rate (₹)</TableHead>
|
<TableHead>Activity</TableHead>
|
||||||
<TableHead>Standard Rate (₹)</TableHead>
|
<TableHead>Contractor Rate (₹)</TableHead>
|
||||||
<TableHead>Difference (₹)</TableHead>
|
<TableHead>Standard Rate (₹)</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Difference (₹)</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Status</TableHead>
|
||||||
<TableBody>
|
</TableHeader>
|
||||||
{comparisons.map((comp, idx) => (
|
<TableBody>
|
||||||
<TableRow key={idx}>
|
{comparisons.map((comp, idx) => (
|
||||||
<TableCell className="font-medium">{comp.sub_department_name || 'All'}</TableCell>
|
<TableRow key={idx}>
|
||||||
<TableCell>
|
<TableCell className="font-medium">
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
{comp.sub_department_name || "All"}
|
||||||
comp.unit_of_measurement === 'Per Bag'
|
</TableCell>
|
||||||
? 'bg-blue-100 text-blue-700'
|
<TableCell>
|
||||||
: 'bg-gray-100 text-gray-700'
|
<span
|
||||||
}`}>
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
{comp.activity || 'Standard'}
|
comp.unit_of_measurement === "Per Bag"
|
||||||
</span>
|
? "bg-blue-100 text-blue-700"
|
||||||
</TableCell>
|
: "bg-gray-100 text-gray-700"
|
||||||
<TableCell className="font-semibold">₹{comp.rate}</TableCell>
|
}`}
|
||||||
<TableCell className="text-gray-600">₹{comp.standard_rate}</TableCell>
|
>
|
||||||
<TableCell>
|
{comp.activity || "Standard"}
|
||||||
<span className={`font-semibold ${
|
|
||||||
comp.difference > 0 ? 'text-red-600' :
|
|
||||||
comp.difference < 0 ? 'text-green-600' :
|
|
||||||
'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{comp.difference > 0 ? '+' : ''}₹{comp.difference.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{comp.is_above_standard ? (
|
|
||||||
<span className="px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-700">
|
|
||||||
Above Standard ({comp.percentage_difference}%)
|
|
||||||
</span>
|
</span>
|
||||||
) : comp.is_below_standard ? (
|
</TableCell>
|
||||||
<span className="px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">
|
<TableCell className="font-semibold">
|
||||||
Below Standard ({comp.percentage_difference}%)
|
₹{comp.rate}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-600">
|
||||||
|
₹{comp.standard_rate}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`font-semibold ${
|
||||||
|
comp.difference > 0
|
||||||
|
? "text-red-600"
|
||||||
|
: comp.difference < 0
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{comp.difference > 0 ? "+" : ""}₹{comp.difference
|
||||||
|
.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
</TableCell>
|
||||||
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
<TableCell>
|
||||||
At Standard
|
{comp.is_above_standard
|
||||||
</span>
|
? (
|
||||||
)}
|
<span className="px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-700">
|
||||||
</TableCell>
|
Above Standard ({comp.percentage_difference}%)
|
||||||
</TableRow>
|
</span>
|
||||||
))}
|
)
|
||||||
</TableBody>
|
: comp.is_below_standard
|
||||||
</Table>
|
? (
|
||||||
) : compareContractorId ? (
|
<span className="px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||||
<div className="text-center py-8 text-gray-500">
|
Below Standard ({comp.percentage_difference}%)
|
||||||
No rates found for this contractor to compare.
|
</span>
|
||||||
</div>
|
)
|
||||||
) : (
|
: (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
||||||
Select a contractor to compare their rates against standard rates.
|
At Standard
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
: compareContractorId
|
||||||
|
? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
No rates found for this contractor to compare.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Select a contractor to compare their rates against standard
|
||||||
|
rates.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,65 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { Plus, RefreshCw, CheckCircle, Trash2, Search } from 'lucide-react';
|
import { CheckCircle, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||||
import { Card, CardContent } from '../components/ui/Card';
|
import { Card, CardContent } from "../components/ui/Card";
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
import {
|
||||||
import { Button } from '../components/ui/Button';
|
Table,
|
||||||
import { Input, Select, TextArea } from '../components/ui/Input';
|
TableBody,
|
||||||
import { useWorkAllocations } from '../hooks/useWorkAllocations';
|
TableCell,
|
||||||
import { useDepartments, useSubDepartments } from '../hooks/useDepartments';
|
TableHead,
|
||||||
import { useEmployees } from '../hooks/useEmployees';
|
TableHeader,
|
||||||
import { useActivities } from '../hooks/useActivities';
|
TableRow,
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
} from "../components/ui/Table.tsx";
|
||||||
import { api } from '../services/api';
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
|
import { Input, Select, TextArea } from "../components/ui/Input.tsx";
|
||||||
|
import { useWorkAllocations } from "../hooks/useWorkAllocations.ts";
|
||||||
|
import { useDepartments, useSubDepartments } from "../hooks/useDepartments.ts";
|
||||||
|
import { useEmployees } from "../hooks/useEmployees.ts";
|
||||||
|
import { useActivities } from "../hooks/useActivities.ts";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.tsx";
|
||||||
|
import { api } from "../services/api.ts";
|
||||||
|
|
||||||
export const WorkAllocationPage: React.FC = () => {
|
export const WorkAllocationPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'create' | 'view' | 'summary'>('view');
|
const [activeTab, setActiveTab] = useState<"create" | "view" | "summary">(
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
"view",
|
||||||
const { allocations, loading, error, refresh, createAllocation, updateAllocation, deleteAllocation } = useWorkAllocations();
|
);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const {
|
||||||
|
allocations,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
createAllocation,
|
||||||
|
updateAllocation,
|
||||||
|
deleteAllocation,
|
||||||
|
} = useWorkAllocations();
|
||||||
const { departments } = useDepartments();
|
const { departments } = useDepartments();
|
||||||
const { employees } = useEmployees();
|
const { employees } = useEmployees();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [contractors, setContractors] = useState<any[]>([]);
|
const [contractors, setContractors] = useState<any[]>([]);
|
||||||
|
|
||||||
// Check if user is supervisor (limited to their department)
|
// Check if user is supervisor (limited to their department)
|
||||||
const isSupervisor = user?.role === 'Supervisor';
|
const isSupervisor = user?.role === "Supervisor";
|
||||||
const canCreateAllocation = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
|
|
||||||
|
|
||||||
// Get supervisor's department name
|
// Get supervisor's department name
|
||||||
const supervisorDeptName = departments.find(d => d.id === user?.department_id)?.name || '';
|
const supervisorDeptName =
|
||||||
|
departments.find((d) => d.id === user?.department_id)?.name || "";
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
employeeId: '',
|
employeeId: "",
|
||||||
contractorId: '',
|
contractorId: "",
|
||||||
subDepartmentId: '',
|
subDepartmentId: "",
|
||||||
activity: '',
|
activity: "",
|
||||||
description: '',
|
description: "",
|
||||||
assignedDate: new Date().toISOString().split('T')[0],
|
assignedDate: new Date().toISOString().split("T")[0],
|
||||||
rateId: '',
|
rateId: "",
|
||||||
departmentId: '',
|
departmentId: "",
|
||||||
units: '',
|
units: "",
|
||||||
});
|
});
|
||||||
const [selectedDept, setSelectedDept] = useState('');
|
const [selectedDept, setSelectedDept] = useState("");
|
||||||
const { subDepartments } = useSubDepartments(selectedDept);
|
const { subDepartments } = useSubDepartments(selectedDept);
|
||||||
const { activities } = useActivities(formData.subDepartmentId);
|
const { activities } = useActivities(formData.subDepartmentId);
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState("");
|
||||||
const [formLoading, setFormLoading] = useState(false);
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
const [contractorRates, setContractorRates] = useState<any[]>([]);
|
const [contractorRates, setContractorRates] = useState<any[]>([]);
|
||||||
|
|
||||||
@@ -58,15 +75,17 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
}, [formData.contractorId]);
|
}, [formData.contractorId]);
|
||||||
|
|
||||||
// Get selected rate details
|
// Get selected rate details
|
||||||
const selectedRate = contractorRates.find(r => r.id === parseInt(formData.rateId));
|
const selectedRate = contractorRates.find((r) =>
|
||||||
|
r.id === parseInt(formData.rateId)
|
||||||
|
);
|
||||||
|
|
||||||
// Get selected activity details
|
// Get selected activity details
|
||||||
const selectedActivity = activities.find(a => a.name === formData.activity);
|
const selectedActivity = activities.find((a) => a.name === formData.activity);
|
||||||
|
|
||||||
// Check if rate is per unit based on activity's unit_of_measurement
|
// Check if rate is per unit based on activity's unit_of_measurement
|
||||||
const isPerUnitRate = selectedActivity?.unit_of_measurement === 'Per Bag' ||
|
const isPerUnitRate = selectedActivity?.unit_of_measurement === "Per Bag" ||
|
||||||
selectedRate?.unit_of_measurement === 'Per Bag';
|
selectedRate?.unit_of_measurement === "Per Bag";
|
||||||
|
|
||||||
// Calculate total amount
|
// Calculate total amount
|
||||||
const unitCount = parseFloat(formData.units) || 0;
|
const unitCount = parseFloat(formData.units) || 0;
|
||||||
const rateAmount = parseFloat(selectedRate?.rate) || 0;
|
const rateAmount = parseFloat(selectedRate?.rate) || 0;
|
||||||
@@ -77,57 +96,74 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
if (isSupervisor && user?.department_id) {
|
if (isSupervisor && user?.department_id) {
|
||||||
const deptId = String(user.department_id);
|
const deptId = String(user.department_id);
|
||||||
setSelectedDept(deptId);
|
setSelectedDept(deptId);
|
||||||
setFormData(prev => ({ ...prev, departmentId: deptId }));
|
setFormData((prev) => ({ ...prev, departmentId: deptId }));
|
||||||
}
|
}
|
||||||
}, [isSupervisor, user?.department_id]);
|
}, [isSupervisor, user?.department_id]);
|
||||||
|
|
||||||
// Load contractors
|
// Load contractors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getUsers({ role: 'Contractor' }).then(setContractors).catch(console.error);
|
api.getUsers({ role: "Contractor" }).then(setContractors).catch(
|
||||||
|
console.error,
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter employees by selected contractor
|
// Filter employees by selected contractor
|
||||||
const filteredEmployees = formData.contractorId
|
const filteredEmployees = formData.contractorId
|
||||||
? employees.filter(e => e.contractor_id === parseInt(formData.contractorId))
|
? employees.filter((e) =>
|
||||||
: employees.filter(e => e.role === 'Employee');
|
e.contractor_id === parseInt(formData.contractorId)
|
||||||
|
)
|
||||||
|
: employees.filter((e) => e.role === "Employee");
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
const handleInputChange = (
|
||||||
|
e: React.ChangeEvent<
|
||||||
|
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
||||||
// Auto-select department when contractor is selected
|
// Auto-select department when contractor is selected
|
||||||
if (name === 'contractorId' && value) {
|
if (name === "contractorId" && value) {
|
||||||
const selectedContractor = contractors.find(c => String(c.id) === value);
|
const selectedContractor = contractors.find((c) =>
|
||||||
|
String(c.id) === value
|
||||||
|
);
|
||||||
if (selectedContractor?.department_id) {
|
if (selectedContractor?.department_id) {
|
||||||
setSelectedDept(String(selectedContractor.department_id));
|
setSelectedDept(String(selectedContractor.department_id));
|
||||||
// Clear sub-department and activity when contractor changes
|
// Clear sub-department and activity when contractor changes
|
||||||
setFormData(prev => ({ ...prev, [name]: value, subDepartmentId: '', activity: '', rateId: '' }));
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
subDepartmentId: "",
|
||||||
|
activity: "",
|
||||||
|
rateId: "",
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
}
|
}
|
||||||
}
|
} // Clear activity when sub-department changes
|
||||||
// Clear activity when sub-department changes
|
else if (name === "subDepartmentId") {
|
||||||
else if (name === 'subDepartmentId') {
|
setFormData((prev) => ({ ...prev, [name]: value, activity: "" }));
|
||||||
setFormData(prev => ({ ...prev, [name]: value, activity: '' }));
|
|
||||||
} else {
|
} else {
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
}
|
}
|
||||||
setFormError('');
|
setFormError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateAllocation = async () => {
|
const handleCreateAllocation = async () => {
|
||||||
if (!formData.employeeId || !formData.contractorId) {
|
if (!formData.employeeId || !formData.contractorId) {
|
||||||
setFormError('Please select employee and contractor');
|
setFormError("Please select employee and contractor");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormLoading(true);
|
setFormLoading(true);
|
||||||
setFormError('');
|
setFormError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createAllocation({
|
await createAllocation({
|
||||||
employeeId: parseInt(formData.employeeId),
|
employeeId: parseInt(formData.employeeId),
|
||||||
contractorId: parseInt(formData.contractorId),
|
contractorId: parseInt(formData.contractorId),
|
||||||
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : null,
|
subDepartmentId: formData.subDepartmentId
|
||||||
|
? parseInt(formData.subDepartmentId)
|
||||||
|
: null,
|
||||||
activity: formData.activity || null,
|
activity: formData.activity || null,
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
assignedDate: formData.assignedDate,
|
assignedDate: formData.assignedDate,
|
||||||
@@ -138,19 +174,19 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
employeeId: '',
|
employeeId: "",
|
||||||
contractorId: '',
|
contractorId: "",
|
||||||
subDepartmentId: '',
|
subDepartmentId: "",
|
||||||
activity: '',
|
activity: "",
|
||||||
description: '',
|
description: "",
|
||||||
assignedDate: new Date().toISOString().split('T')[0],
|
assignedDate: new Date().toISOString().split("T")[0],
|
||||||
rateId: '',
|
rateId: "",
|
||||||
departmentId: isSupervisor ? String(user?.department_id) : '',
|
departmentId: isSupervisor ? String(user?.department_id) : "",
|
||||||
units: '',
|
units: "",
|
||||||
});
|
});
|
||||||
setActiveTab('view');
|
setActiveTab("view");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setFormError(err.message || 'Failed to create allocation');
|
setFormError(err.message || "Failed to create allocation");
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
@@ -158,27 +194,31 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleMarkComplete = async (id: number) => {
|
const handleMarkComplete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await updateAllocation(id, 'Completed', new Date().toISOString().split('T')[0]);
|
await updateAllocation(
|
||||||
|
id,
|
||||||
|
"Completed",
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to update allocation');
|
alert(err.message || "Failed to update allocation");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('Are you sure you want to delete this allocation?')) return;
|
if (!confirm("Are you sure you want to delete this allocation?")) return;
|
||||||
try {
|
try {
|
||||||
await deleteAllocation(id);
|
await deleteAllocation(id);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to delete allocation');
|
alert(err.message || "Failed to delete allocation");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate summary stats
|
// Calculate summary stats
|
||||||
const stats = {
|
const stats = {
|
||||||
total: allocations.length,
|
total: allocations.length,
|
||||||
completed: allocations.filter(a => a.status === 'Completed').length,
|
completed: allocations.filter((a) => a.status === "Completed").length,
|
||||||
inProgress: allocations.filter(a => a.status === 'InProgress').length,
|
inProgress: allocations.filter((a) => a.status === "InProgress").length,
|
||||||
pending: allocations.filter(a => a.status === 'Pending').length,
|
pending: allocations.filter((a) => a.status === "Pending").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,29 +226,31 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<div className="flex space-x-8 px-6">
|
<div className="flex space-x-8 px-6">
|
||||||
{['create', 'view', 'summary'].map((tab) => (
|
{["create", "view", "summary"].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab as any)}
|
onClick={() => setActiveTab(tab as any)}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'border-blue-500 text-blue-600'
|
? "border-blue-500 text-blue-600"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === 'create' && 'Create Allocation'}
|
{tab === "create" && "Create Allocation"}
|
||||||
{tab === 'view' && 'View Allocations'}
|
{tab === "view" && "View Allocations"}
|
||||||
{tab === 'summary' && 'Work Summary'}
|
{tab === "summary" && "Work Summary"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeTab === 'create' && (
|
{activeTab === "create" && (
|
||||||
<div className="max-w-3xl space-y-6">
|
<div className="max-w-3xl space-y-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-800">Create New Work Allocation</h3>
|
<h3 className="text-lg font-semibold text-gray-800">
|
||||||
|
Create New Work Allocation
|
||||||
|
</h3>
|
||||||
|
|
||||||
{formError && (
|
{formError && (
|
||||||
<div className="p-3 bg-red-100 text-red-700 rounded-md">
|
<div className="p-3 bg-red-100 text-red-700 rounded-md">
|
||||||
{formError}
|
{formError}
|
||||||
@@ -223,8 +265,11 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select Contractor' },
|
{ value: "", label: "Select Contractor" },
|
||||||
...contractors.map(c => ({ value: String(c.id), label: c.name }))
|
...contractors.map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -234,35 +279,46 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select Employee' },
|
{ value: "", label: "Select Employee" },
|
||||||
...filteredEmployees.map(e => ({ value: String(e.id), label: e.name }))
|
...filteredEmployees.map((e) => ({
|
||||||
|
value: String(e.id),
|
||||||
|
label: e.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{isSupervisor ? (
|
{isSupervisor
|
||||||
<Input
|
? (
|
||||||
label="Department"
|
<Input
|
||||||
value={supervisorDeptName || 'Loading...'}
|
label="Department"
|
||||||
disabled
|
value={supervisorDeptName || "Loading..."}
|
||||||
/>
|
disabled
|
||||||
) : (
|
/>
|
||||||
<Select
|
)
|
||||||
label="Department"
|
: (
|
||||||
value={selectedDept}
|
<Select
|
||||||
onChange={(e) => setSelectedDept(e.target.value)}
|
label="Department"
|
||||||
options={[
|
value={selectedDept}
|
||||||
{ value: '', label: 'Select Department' },
|
onChange={(e) => setSelectedDept(e.target.value)}
|
||||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
options={[
|
||||||
]}
|
{ value: "", label: "Select Department" },
|
||||||
/>
|
...departments.map((d) => ({
|
||||||
)}
|
value: String(d.id),
|
||||||
|
label: d.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Select
|
<Select
|
||||||
label="Sub-Department"
|
label="Sub-Department"
|
||||||
name="subDepartmentId"
|
name="subDepartmentId"
|
||||||
value={formData.subDepartmentId}
|
value={formData.subDepartmentId}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Select Sub-Department' },
|
{ value: "", label: "Select Sub-Department" },
|
||||||
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
|
...subDepartments.map((s) => ({
|
||||||
|
value: String(s.id),
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -272,11 +328,20 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
disabled={!formData.subDepartmentId}
|
disabled={!formData.subDepartmentId}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: formData.subDepartmentId ? 'Select Activity' : 'Select Sub-Department First' },
|
{
|
||||||
...activities.map(a => ({
|
value: "",
|
||||||
value: a.name,
|
label: formData.subDepartmentId
|
||||||
label: `${a.name} (${a.unit_of_measurement === 'Per Bag' ? 'per unit' : 'flat rate'})`
|
? "Select Activity"
|
||||||
}))
|
: "Select Sub-Department First",
|
||||||
|
},
|
||||||
|
...activities.map((a) => ({
|
||||||
|
value: a.name,
|
||||||
|
label: `${a.name} (${
|
||||||
|
a.unit_of_measurement === "Per Bag"
|
||||||
|
? "per unit"
|
||||||
|
: "flat rate"
|
||||||
|
})`,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -294,11 +359,20 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
disabled={!formData.contractorId}
|
disabled={!formData.contractorId}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: formData.contractorId ? 'Select Rate' : 'Select Contractor First' },
|
{
|
||||||
...contractorRates.map(r => ({
|
value: "",
|
||||||
value: String(r.id),
|
label: formData.contractorId
|
||||||
label: `₹${r.rate} - ${r.activity || 'Standard'} ${r.sub_department_name ? `(${r.sub_department_name})` : ''}`
|
? "Select Rate"
|
||||||
}))
|
: "Select Contractor First",
|
||||||
|
},
|
||||||
|
...contractorRates.map((r) => ({
|
||||||
|
value: String(r.id),
|
||||||
|
label: `₹${r.rate} - ${r.activity || "Standard"} ${
|
||||||
|
r.sub_department_name
|
||||||
|
? `(${r.sub_department_name})`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{isPerUnitRate && (
|
{isPerUnitRate && (
|
||||||
@@ -322,37 +396,51 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calculation Box */}
|
{/* Calculation Box */}
|
||||||
{selectedRate && (
|
{selectedRate && (
|
||||||
<div className="col-span-2 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
<div className="col-span-2 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
<h4 className="font-semibold text-blue-800 mb-3">Rate Calculation</h4>
|
<h4 className="font-semibold text-blue-800 mb-3">
|
||||||
|
Rate Calculation
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Rate Type:</span>
|
<span className="text-gray-600">Rate Type:</span>
|
||||||
<span className="ml-2 font-medium">{isPerUnitRate ? 'Per Unit' : 'Flat Rate'}</span>
|
<span className="ml-2 font-medium">
|
||||||
|
{isPerUnitRate ? "Per Unit" : "Flat Rate"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Rate:</span>
|
<span className="text-gray-600">Rate:</span>
|
||||||
<span className="ml-2 font-medium">₹{rateAmount.toFixed(2)}</span>
|
<span className="ml-2 font-medium">
|
||||||
|
₹{rateAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isPerUnitRate && (
|
{isPerUnitRate && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Units:</span>
|
<span className="text-gray-600">Units:</span>
|
||||||
<span className="ml-2 font-medium">{unitCount || 0}</span>
|
<span className="ml-2 font-medium">
|
||||||
|
{unitCount || 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600">Calculation:</span>
|
<span className="text-gray-600">Calculation:</span>
|
||||||
<span className="ml-2 font-medium">{unitCount} × ₹{rateAmount.toFixed(2)}</span>
|
<span className="ml-2 font-medium">
|
||||||
|
{unitCount} × ₹{rateAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 pt-3 border-t border-blue-300">
|
<div className="mt-4 pt-3 border-t border-blue-300">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-lg font-semibold text-blue-800">Total Amount:</span>
|
<span className="text-lg font-semibold text-blue-800">
|
||||||
<span className="text-2xl font-bold text-green-600">₹{totalAmount.toFixed(2)}</span>
|
Total Amount:
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-green-600">
|
||||||
|
₹{totalAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,11 +448,11 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4 mt-6">
|
<div className="flex justify-end gap-4 mt-6">
|
||||||
<Button variant="outline" onClick={() => setActiveTab('view')}>
|
<Button variant="outline" onClick={() => setActiveTab("view")}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreateAllocation} disabled={formLoading}>
|
<Button onClick={handleCreateAllocation} disabled={formLoading}>
|
||||||
{formLoading ? 'Creating...' : (
|
{formLoading ? "Creating..." : (
|
||||||
<>
|
<>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Create Allocation
|
Create Allocation
|
||||||
@@ -375,11 +463,14 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'view' && (
|
{activeTab === "view" && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by employee, contractor, sub-department..."
|
placeholder="Search by employee, contractor, sub-department..."
|
||||||
@@ -399,9 +490,9 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
Error: {error}
|
Error: {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const filteredAllocations = allocations.filter(a => {
|
const filteredAllocations = allocations.filter((a) => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -412,122 +503,187 @@ export const WorkAllocationPage: React.FC = () => {
|
|||||||
a.status?.toLowerCase().includes(query)
|
a.status?.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return loading ? (
|
return loading
|
||||||
<div className="text-center py-8">Loading work allocations...</div>
|
? (
|
||||||
) : filteredAllocations.length > 0 ? (
|
<div className="text-center py-8">
|
||||||
<Table>
|
Loading work allocations...
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableHead>ID</TableHead>
|
)
|
||||||
<TableHead>Employee</TableHead>
|
: filteredAllocations.length > 0
|
||||||
<TableHead>Contractor</TableHead>
|
? (
|
||||||
<TableHead>Sub-Department</TableHead>
|
<Table>
|
||||||
<TableHead>Activity</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Rate Details</TableHead>
|
<TableHead>Employee</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Contractor</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Sub-Department</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Activity</TableHead>
|
||||||
<TableBody>
|
<TableHead>Date</TableHead>
|
||||||
{filteredAllocations.map((allocation) => {
|
<TableHead>Rate Details</TableHead>
|
||||||
const isPerUnit = allocation.activity === 'Loading' || allocation.activity === 'Unloading';
|
<TableHead>Status</TableHead>
|
||||||
const units = parseFloat(allocation.units) || 0;
|
<TableHead>Actions</TableHead>
|
||||||
const rate = parseFloat(allocation.rate) || 0;
|
</TableHeader>
|
||||||
const total = parseFloat(allocation.total_amount) || (isPerUnit ? units * rate : rate);
|
<TableBody>
|
||||||
|
{filteredAllocations.map((allocation) => {
|
||||||
return (
|
const isPerUnit = allocation.activity === "Loading" ||
|
||||||
<TableRow key={allocation.id}>
|
allocation.activity === "Unloading";
|
||||||
<TableCell>{allocation.id}</TableCell>
|
const units = parseFloat(allocation.units) || 0;
|
||||||
<TableCell>{allocation.employee_name || '-'}</TableCell>
|
const rate = parseFloat(allocation.rate) || 0;
|
||||||
<TableCell>{allocation.contractor_name || '-'}</TableCell>
|
const total = parseFloat(allocation.total_amount) ||
|
||||||
<TableCell>{allocation.sub_department_name || '-'}</TableCell>
|
(isPerUnit ? units * rate : rate);
|
||||||
<TableCell>
|
|
||||||
{allocation.activity ? (
|
return (
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
<TableRow key={allocation.id}>
|
||||||
allocation.activity === 'Loading' || allocation.activity === 'Unloading'
|
<TableCell>{allocation.id}</TableCell>
|
||||||
? 'bg-purple-100 text-purple-700'
|
<TableCell>
|
||||||
: 'bg-gray-100 text-gray-700'
|
{allocation.employee_name || "-"}
|
||||||
}`}>
|
</TableCell>
|
||||||
{allocation.activity}
|
<TableCell>
|
||||||
</span>
|
{allocation.contractor_name || "-"}
|
||||||
) : '-'}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>{new Date(allocation.assigned_date).toLocaleDateString()}</TableCell>
|
{allocation.sub_department_name || "-"}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
{rate > 0 ? (
|
<TableCell>
|
||||||
<div className="text-sm">
|
{allocation.activity
|
||||||
{isPerUnit && units > 0 ? (
|
? (
|
||||||
<div>
|
<span
|
||||||
<div className="text-gray-500">{units} × ₹{rate.toFixed(2)}</div>
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
<div className="font-semibold text-green-600">= ₹{total.toFixed(2)}</div>
|
allocation.activity === "Loading" ||
|
||||||
</div>
|
allocation.activity === "Unloading"
|
||||||
) : (
|
? "bg-purple-100 text-purple-700"
|
||||||
<div className="font-semibold text-green-600">₹{rate.toFixed(2)}</div>
|
: "bg-gray-100 text-gray-700"
|
||||||
)}
|
}`}
|
||||||
</div>
|
>
|
||||||
) : '-'}
|
{allocation.activity}
|
||||||
</TableCell>
|
</span>
|
||||||
<TableCell>
|
)
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
: "-"}
|
||||||
allocation.status === 'Completed' ? 'bg-green-100 text-green-700' :
|
</TableCell>
|
||||||
allocation.status === 'InProgress' ? 'bg-blue-100 text-blue-700' :
|
<TableCell>
|
||||||
allocation.status === 'Cancelled' ? 'bg-red-100 text-red-700' :
|
{new Date(allocation.assigned_date)
|
||||||
'bg-yellow-100 text-yellow-700'
|
.toLocaleDateString()}
|
||||||
}`}>
|
</TableCell>
|
||||||
{allocation.status}
|
<TableCell>
|
||||||
</span>
|
{rate > 0
|
||||||
</TableCell>
|
? (
|
||||||
<TableCell>
|
<div className="text-sm">
|
||||||
<div className="flex gap-2">
|
{isPerUnit && units > 0
|
||||||
{allocation.status !== 'Completed' && (
|
? (
|
||||||
<Button
|
<div>
|
||||||
variant="ghost"
|
<div className="text-gray-500">
|
||||||
size="sm"
|
{units} × ₹{rate.toFixed(2)}
|
||||||
onClick={() => handleMarkComplete(allocation.id)}
|
</div>
|
||||||
className="text-green-600"
|
<div className="font-semibold text-green-600">
|
||||||
title="Mark Complete"
|
= ₹{total.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="font-semibold text-green-600">
|
||||||
|
₹{rate.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
allocation.status === "Completed"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: allocation.status === "InProgress"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: allocation.status === "Cancelled"
|
||||||
|
? "bg-red-100 text-red-700"
|
||||||
|
: "bg-yellow-100 text-yellow-700"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<CheckCircle size={14} />
|
{allocation.status}
|
||||||
</Button>
|
</span>
|
||||||
)}
|
</TableCell>
|
||||||
<Button
|
<TableCell>
|
||||||
variant="ghost"
|
<div className="flex gap-2">
|
||||||
size="sm"
|
{allocation.status !== "Completed" && (
|
||||||
onClick={() => handleDelete(allocation.id)}
|
<Button
|
||||||
className="text-red-600"
|
variant="ghost"
|
||||||
title="Delete"
|
size="sm"
|
||||||
>
|
onClick={() =>
|
||||||
<Trash2 size={14} />
|
handleMarkComplete(allocation.id)}
|
||||||
</Button>
|
className="text-green-600"
|
||||||
</div>
|
title="Mark Complete"
|
||||||
</TableCell>
|
>
|
||||||
</TableRow>
|
<CheckCircle size={14} />
|
||||||
);
|
</Button>
|
||||||
})}
|
)}
|
||||||
</TableBody>
|
<Button
|
||||||
</Table>
|
variant="ghost"
|
||||||
) : (
|
size="sm"
|
||||||
<div className="text-center py-8 text-gray-500">
|
onClick={() => handleDelete(allocation.id)}
|
||||||
{searchQuery ? 'No matching allocations found.' : 'No work allocations found. Create one to get started!'}
|
className="text-red-600"
|
||||||
</div>
|
title="Delete"
|
||||||
)
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
{searchQuery
|
||||||
|
? "No matching allocations found."
|
||||||
|
: "No work allocations found. Create one to get started!"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'summary' && (
|
{activeTab === "summary" && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-6">Work Summary & Statistics</h3>
|
<h3 className="text-lg font-semibold text-gray-800 mb-6">
|
||||||
|
Work Summary & Statistics
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-4 gap-6">
|
<div className="grid grid-cols-4 gap-6">
|
||||||
{[
|
{[
|
||||||
{ label: 'TOTAL ALLOCATIONS', value: stats.total, color: 'bg-gray-100' },
|
{
|
||||||
{ label: 'COMPLETED', value: stats.completed, color: 'bg-green-100' },
|
label: "TOTAL ALLOCATIONS",
|
||||||
{ label: 'IN PROGRESS', value: stats.inProgress, color: 'bg-blue-100' },
|
value: stats.total,
|
||||||
{ label: 'PENDING', value: stats.pending, color: 'bg-yellow-100' },
|
color: "bg-gray-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "COMPLETED",
|
||||||
|
value: stats.completed,
|
||||||
|
color: "bg-green-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "IN PROGRESS",
|
||||||
|
value: stats.inProgress,
|
||||||
|
color: "bg-blue-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "PENDING",
|
||||||
|
value: stats.pending,
|
||||||
|
color: "bg-yellow-100",
|
||||||
|
},
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div key={stat.label} className={`${stat.color} border border-gray-200 rounded-lg p-6`}>
|
<div
|
||||||
<div className="text-xs text-gray-500 mb-2">{stat.label}</div>
|
key={stat.label}
|
||||||
<div className="text-3xl font-bold text-gray-800">{stat.value}</div>
|
className={`${stat.color} border border-gray-200 rounded-lg p-6`}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-gray-500 mb-2">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-800">
|
||||||
|
{stat.value}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ||
|
||||||
|
"http://localhost:3000/api";
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
@@ -8,18 +9,21 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getToken(): string | null {
|
private getToken(): string | null {
|
||||||
return localStorage.getItem('token');
|
return localStorage.getItem("token");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
const token = this.getToken();
|
const token = this.getToken();
|
||||||
const headers: HeadersInit = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
...options.headers,
|
...(options.headers as Record<string, string>),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
@@ -28,183 +32,216 @@ class ApiService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem("user");
|
||||||
window.location.href = '/';
|
globalThis.location.href = "/";
|
||||||
throw new Error('Unauthorized');
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
const error = await response.json().catch(() => ({
|
||||||
throw new Error(error.error || 'Request failed');
|
error: "Request failed",
|
||||||
|
}));
|
||||||
|
throw new Error(error.error || "Request failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
async login(username: string, password: string) {
|
login(username: string, password: string) {
|
||||||
return this.request<{ token: string; user: any }>('/auth/login', {
|
return this.request<{ token: string; user: any }>("/auth/login", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMe() {
|
getMe() {
|
||||||
return this.request<any>('/auth/me');
|
return this.request<any>("/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
async changePassword(currentPassword: string, newPassword: string) {
|
changePassword(currentPassword: string, newPassword: string) {
|
||||||
return this.request<{ message: string }>('/auth/change-password', {
|
return this.request<{ message: string }>("/auth/change-password", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
async getUsers(params?: { role?: string; departmentId?: number }) {
|
getUsers(params?: { role?: string; departmentId?: number }) {
|
||||||
const query = new URLSearchParams(params as any).toString();
|
const query = new URLSearchParams(params as any).toString();
|
||||||
return this.request<any[]>(`/users${query ? `?${query}` : ''}`);
|
return this.request<any[]>(`/users${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUser(id: number) {
|
getUser(id: number) {
|
||||||
return this.request<any>(`/users/${id}`);
|
return this.request<any>(`/users/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(data: any) {
|
createUser(data: any) {
|
||||||
return this.request<any>('/users', {
|
return this.request<any>("/users", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(id: number, data: any) {
|
updateUser(id: number, data: any) {
|
||||||
return this.request<any>(`/users/${id}`, {
|
return this.request<any>(`/users/${id}`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUser(id: number) {
|
deleteUser(id: number) {
|
||||||
return this.request<{ message: string }>(`/users/${id}`, {
|
return this.request<{ message: string }>(`/users/${id}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Departments
|
// Departments
|
||||||
async getDepartments() {
|
getDepartments() {
|
||||||
return this.request<any[]>('/departments');
|
return this.request<any[]>("/departments");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDepartment(id: number) {
|
getDepartment(id: number) {
|
||||||
return this.request<any>(`/departments/${id}`);
|
return this.request<any>(`/departments/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSubDepartments(departmentId: number) {
|
getSubDepartments(departmentId: number) {
|
||||||
return this.request<any[]>(`/departments/${departmentId}/sub-departments`);
|
return this.request<any[]>(`/departments/${departmentId}/sub-departments`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDepartment(name: string) {
|
getAllSubDepartments() {
|
||||||
return this.request<any>('/departments', {
|
return this.request<any[]>("/departments/sub-departments/all");
|
||||||
method: 'POST',
|
}
|
||||||
|
|
||||||
|
createDepartment(name: string) {
|
||||||
|
return this.request<any>("/departments", {
|
||||||
|
method: "POST",
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sub-Departments
|
// Sub-Departments
|
||||||
async createSubDepartment(data: { department_id: number; name: string }) {
|
createSubDepartment(data: { department_id: number; name: string }) {
|
||||||
return this.request<any>('/departments/sub-departments', {
|
return this.request<any>("/departments/sub-departments", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSubDepartment(id: number) {
|
deleteSubDepartment(id: number) {
|
||||||
return this.request<{ message: string }>(`/departments/sub-departments/${id}`, {
|
return this.request<{ message: string }>(
|
||||||
method: 'DELETE',
|
`/departments/sub-departments/${id}`,
|
||||||
});
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work Allocations
|
// Work Allocations
|
||||||
async getWorkAllocations(params?: { employeeId?: number; status?: string; departmentId?: number }) {
|
getWorkAllocations(
|
||||||
|
params?: { employeeId?: number; status?: string; departmentId?: number },
|
||||||
|
) {
|
||||||
const query = new URLSearchParams(params as any).toString();
|
const query = new URLSearchParams(params as any).toString();
|
||||||
return this.request<any[]>(`/work-allocations${query ? `?${query}` : ''}`);
|
return this.request<any[]>(`/work-allocations${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWorkAllocation(id: number) {
|
getWorkAllocation(id: number) {
|
||||||
return this.request<any>(`/work-allocations/${id}`);
|
return this.request<any>(`/work-allocations/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createWorkAllocation(data: any) {
|
createWorkAllocation(data: any) {
|
||||||
return this.request<any>('/work-allocations', {
|
return this.request<any>("/work-allocations", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateWorkAllocationStatus(id: number, status: string, completionDate?: string) {
|
updateWorkAllocationStatus(
|
||||||
|
id: number,
|
||||||
|
status: string,
|
||||||
|
completionDate?: string,
|
||||||
|
) {
|
||||||
return this.request<any>(`/work-allocations/${id}/status`, {
|
return this.request<any>(`/work-allocations/${id}/status`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body: JSON.stringify({ status, completionDate }),
|
body: JSON.stringify({ status, completionDate }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteWorkAllocation(id: number) {
|
deleteWorkAllocation(id: number) {
|
||||||
return this.request<{ message: string }>(`/work-allocations/${id}`, {
|
return this.request<{ message: string }>(`/work-allocations/${id}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attendance
|
// Attendance
|
||||||
async getAttendance(params?: { employeeId?: number; startDate?: string; endDate?: string; status?: string }) {
|
getAttendance(
|
||||||
|
params?: {
|
||||||
|
employeeId?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
status?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
const query = new URLSearchParams(params as any).toString();
|
const query = new URLSearchParams(params as any).toString();
|
||||||
return this.request<any[]>(`/attendance${query ? `?${query}` : ''}`);
|
return this.request<any[]>(`/attendance${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkIn(employeeId: number, workDate: string) {
|
checkIn(employeeId: number, workDate: string) {
|
||||||
return this.request<any>('/attendance/check-in', {
|
return this.request<any>("/attendance/check-in", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ employeeId, workDate }),
|
body: JSON.stringify({ employeeId, workDate }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkOut(employeeId: number, workDate: string) {
|
checkOut(employeeId: number, workDate: string) {
|
||||||
return this.request<any>('/attendance/check-out', {
|
return this.request<any>("/attendance/check-out", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ employeeId, workDate }),
|
body: JSON.stringify({ employeeId, workDate }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAttendanceSummary(params?: { startDate?: string; endDate?: string; departmentId?: number }) {
|
getAttendanceSummary(
|
||||||
|
params?: { startDate?: string; endDate?: string; departmentId?: number },
|
||||||
|
) {
|
||||||
const query = new URLSearchParams(params as any).toString();
|
const query = new URLSearchParams(params as any).toString();
|
||||||
return this.request<any[]>(`/attendance/summary/stats${query ? `?${query}` : ''}`);
|
return this.request<any[]>(
|
||||||
|
`/attendance/summary/stats${query ? `?${query}` : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAttendanceStatus(id: number, status: string, remark?: string) {
|
updateAttendanceStatus(id: number, status: string, remark?: string) {
|
||||||
return this.request<any>(`/attendance/${id}/status`, {
|
return this.request<any>(`/attendance/${id}/status`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body: JSON.stringify({ status, remark }),
|
body: JSON.stringify({ status, remark }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAbsent(employeeId: number, workDate: string, remark?: string) {
|
markAbsent(employeeId: number, workDate: string, remark?: string) {
|
||||||
return this.request<any>('/attendance/mark-absent', {
|
return this.request<any>("/attendance/mark-absent", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ employeeId, workDate, remark }),
|
body: JSON.stringify({ employeeId, workDate, remark }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Employee Swaps
|
// Employee Swaps
|
||||||
async getEmployeeSwaps(params?: { status?: string; employeeId?: number; startDate?: string; endDate?: string }) {
|
getEmployeeSwaps(
|
||||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
params?: {
|
||||||
return this.request<any[]>(`/employee-swaps${query ? `?${query}` : ''}`);
|
status?: string;
|
||||||
|
employeeId?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const query = params ? new URLSearchParams(params as any).toString() : "";
|
||||||
|
return this.request<any[]>(`/employee-swaps${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEmployeeSwap(id: number) {
|
getEmployeeSwap(id: number) {
|
||||||
return this.request<any>(`/employee-swaps/${id}`);
|
return this.request<any>(`/employee-swaps/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createEmployeeSwap(data: {
|
createEmployeeSwap(data: {
|
||||||
employeeId: number;
|
employeeId: number;
|
||||||
targetDepartmentId: number;
|
targetDepartmentId: number;
|
||||||
targetContractorId?: number;
|
targetContractorId?: number;
|
||||||
@@ -213,160 +250,195 @@ class ApiService {
|
|||||||
workCompletionPercentage?: number;
|
workCompletionPercentage?: number;
|
||||||
swapDate: string;
|
swapDate: string;
|
||||||
}) {
|
}) {
|
||||||
return this.request<any>('/employee-swaps', {
|
return this.request<any>("/employee-swaps", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeEmployeeSwap(id: number) {
|
completeEmployeeSwap(id: number) {
|
||||||
return this.request<any>(`/employee-swaps/${id}/complete`, {
|
return this.request<any>(`/employee-swaps/${id}/complete`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelEmployeeSwap(id: number) {
|
cancelEmployeeSwap(id: number) {
|
||||||
return this.request<any>(`/employee-swaps/${id}/cancel`, {
|
return this.request<any>(`/employee-swaps/${id}/cancel`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contractor Rates
|
// Contractor Rates
|
||||||
async getContractorRates(params?: { contractorId?: number; subDepartmentId?: number }) {
|
getContractorRates(
|
||||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
params?: { contractorId?: number; subDepartmentId?: number },
|
||||||
return this.request<any[]>(`/contractor-rates${query ? `?${query}` : ''}`);
|
) {
|
||||||
|
const query = params ? new URLSearchParams(params as any).toString() : "";
|
||||||
|
return this.request<any[]>(`/contractor-rates${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentRate(contractorId: number, subDepartmentId?: number) {
|
getCurrentRate(contractorId: number, subDepartmentId?: number) {
|
||||||
const query = subDepartmentId ? `?subDepartmentId=${subDepartmentId}` : '';
|
const query = subDepartmentId ? `?subDepartmentId=${subDepartmentId}` : "";
|
||||||
return this.request<any>(`/contractor-rates/contractor/${contractorId}/current${query}`);
|
return this.request<any>(
|
||||||
|
`/contractor-rates/contractor/${contractorId}/current${query}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setContractorRate(data: {
|
setContractorRate(data: {
|
||||||
contractorId: number;
|
contractorId: number;
|
||||||
subDepartmentId?: number;
|
subDepartmentId?: number;
|
||||||
activity?: string;
|
activity?: string;
|
||||||
rate: number;
|
rate: number;
|
||||||
effectiveDate: string
|
effectiveDate: string;
|
||||||
}) {
|
}) {
|
||||||
return this.request<any>('/contractor-rates', {
|
return this.request<any>("/contractor-rates", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateContractorRate(id: number, data: { rate?: number; activity?: string; effectiveDate?: string }) {
|
updateContractorRate(
|
||||||
|
id: number,
|
||||||
|
data: { rate?: number; activity?: string; effectiveDate?: string },
|
||||||
|
) {
|
||||||
return this.request<any>(`/contractor-rates/${id}`, {
|
return this.request<any>(`/contractor-rates/${id}`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteContractorRate(id: number) {
|
deleteContractorRate(id: number) {
|
||||||
return this.request<{ message: string }>(`/contractor-rates/${id}`, {
|
return this.request<{ message: string }>(`/contractor-rates/${id}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reports
|
// Reports
|
||||||
async getCompletedAllocationsReport(params?: {
|
getCompletedAllocationsReport(params?: {
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
departmentId?: number;
|
departmentId?: number;
|
||||||
contractorId?: number;
|
contractorId?: number;
|
||||||
employeeId?: number;
|
employeeId?: number;
|
||||||
}) {
|
}) {
|
||||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
const query = params ? new URLSearchParams(params as any).toString() : "";
|
||||||
return this.request<{
|
return this.request<{
|
||||||
allocations: any[];
|
allocations: any[];
|
||||||
summary: { totalAllocations: number; totalAmount: string; totalUnits: string }
|
summary: {
|
||||||
}>(`/reports/completed-allocations${query ? `?${query}` : ''}`);
|
totalAllocations: number;
|
||||||
|
totalAmount: string;
|
||||||
|
totalUnits: string;
|
||||||
|
};
|
||||||
|
}>(`/reports/completed-allocations${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportSummary(params?: { startDate?: string; endDate?: string }) {
|
getReportSummary(params?: { startDate?: string; endDate?: string }) {
|
||||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
const query = params ? new URLSearchParams(params as any).toString() : "";
|
||||||
return this.request<{
|
return this.request<{
|
||||||
byContractor: any[];
|
byContractor: any[];
|
||||||
bySubDepartment: any[];
|
bySubDepartment: any[];
|
||||||
byActivity: any[];
|
byActivity: any[];
|
||||||
}>(`/reports/summary${query ? `?${query}` : ''}`);
|
}>(`/reports/summary${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard Rates
|
// Standard Rates
|
||||||
async getStandardRates(params?: { departmentId?: number; subDepartmentId?: number; activity?: string }) {
|
getStandardRates(
|
||||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
params?: {
|
||||||
return this.request<any[]>(`/standard-rates${query ? `?${query}` : ''}`);
|
departmentId?: number;
|
||||||
|
subDepartmentId?: number;
|
||||||
|
activity?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const query = params ? new URLSearchParams(params as any).toString() : "";
|
||||||
|
return this.request<any[]>(`/standard-rates${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRates(params?: { departmentId?: number; startDate?: string; endDate?: string }) {
|
getAllRates(
|
||||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
params?: { departmentId?: number; startDate?: string; endDate?: string },
|
||||||
|
) {
|
||||||
|
const query = params ? new URLSearchParams(params as any).toString() : "";
|
||||||
return this.request<{
|
return this.request<{
|
||||||
allRates: any[];
|
allRates: any[];
|
||||||
summary: { totalContractorRates: number; totalStandardRates: number; totalRates: number };
|
summary: {
|
||||||
}>(`/standard-rates/all-rates${query ? `?${query}` : ''}`);
|
totalContractorRates: number;
|
||||||
|
totalStandardRates: number;
|
||||||
|
totalRates: number;
|
||||||
|
};
|
||||||
|
}>(`/standard-rates/all-rates${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async compareRates(params?: { contractorId?: number; subDepartmentId?: number }) {
|
compareRates(params?: { contractorId?: number; subDepartmentId?: number }) {
|
||||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
const query = params ? new URLSearchParams(params as any).toString() : "";
|
||||||
return this.request<{
|
return this.request<{
|
||||||
standardRates: any[];
|
standardRates: any[];
|
||||||
contractorRates: any[];
|
contractorRates: any[];
|
||||||
comparisons: any[];
|
comparisons: any[];
|
||||||
}>(`/standard-rates/compare${query ? `?${query}` : ''}`);
|
}>(`/standard-rates/compare${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createStandardRate(data: {
|
createStandardRate(data: {
|
||||||
subDepartmentId?: number;
|
subDepartmentId?: number;
|
||||||
activity?: string;
|
activity?: string;
|
||||||
rate: number;
|
rate: number;
|
||||||
effectiveDate: string
|
effectiveDate: string;
|
||||||
}) {
|
}) {
|
||||||
return this.request<any>('/standard-rates', {
|
return this.request<any>("/standard-rates", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStandardRate(id: number, data: { rate?: number; activity?: string; effectiveDate?: string }) {
|
updateStandardRate(
|
||||||
|
id: number,
|
||||||
|
data: { rate?: number; activity?: string; effectiveDate?: string },
|
||||||
|
) {
|
||||||
return this.request<any>(`/standard-rates/${id}`, {
|
return this.request<any>(`/standard-rates/${id}`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteStandardRate(id: number) {
|
deleteStandardRate(id: number) {
|
||||||
return this.request<{ message: string }>(`/standard-rates/${id}`, {
|
return this.request<{ message: string }>(`/standard-rates/${id}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activities
|
// Activities
|
||||||
async getActivities(params?: { departmentId?: number; subDepartmentId?: number }) {
|
getActivities(params?: { departmentId?: number; subDepartmentId?: number }) {
|
||||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
const query = params ? new URLSearchParams(params as any).toString() : "";
|
||||||
return this.request<any[]>(`/activities${query ? `?${query}` : ''}`);
|
return this.request<any[]>(`/activities${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActivity(id: number) {
|
getActivity(id: number) {
|
||||||
return this.request<any>(`/activities/${id}`);
|
return this.request<any>(`/activities/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createActivity(data: { sub_department_id: number; name: string; unit_of_measurement?: string }) {
|
createActivity(
|
||||||
return this.request<any>('/activities', {
|
data: {
|
||||||
method: 'POST',
|
sub_department_id: number;
|
||||||
|
name: string;
|
||||||
|
unit_of_measurement?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.request<any>("/activities", {
|
||||||
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateActivity(id: number, data: { name?: string; unit_of_measurement?: string }) {
|
updateActivity(
|
||||||
|
id: number,
|
||||||
|
data: { name?: string; unit_of_measurement?: string },
|
||||||
|
) {
|
||||||
return this.request<any>(`/activities/${id}`, {
|
return this.request<any>(`/activities/${id}`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteActivity(id: number) {
|
deleteActivity(id: number) {
|
||||||
return this.request<{ message: string }>(`/activities/${id}`, {
|
return this.request<{ message: string }>(`/activities/${id}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/types.ts
31
src/types.ts
@@ -4,7 +4,7 @@ export interface Employee {
|
|||||||
dept: string;
|
dept: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
status: 'Present' | 'Absent';
|
status: "Present" | "Absent";
|
||||||
in: string;
|
in: string;
|
||||||
out: string;
|
out: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
@@ -56,3 +56,32 @@ export interface ChartData {
|
|||||||
color?: string;
|
color?: string;
|
||||||
fill?: string;
|
fill?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AttendanceStatus = "CheckedIn" | "CheckedOut" | "Absent" | "HalfDay" | "Late";
|
||||||
|
|
||||||
|
export type SwapStatus = "Active" | "Completed" | "Cancelled";
|
||||||
|
|
||||||
|
export type SwapReason = "LeftWork" | "Sick" | "FinishedEarly" | "Other";
|
||||||
|
|
||||||
|
export interface EmployeeSwap {
|
||||||
|
id: number;
|
||||||
|
employee_id: number;
|
||||||
|
employee_name?: string;
|
||||||
|
original_department_id: number;
|
||||||
|
original_department_name?: string;
|
||||||
|
original_contractor_id?: number;
|
||||||
|
original_contractor_name?: string;
|
||||||
|
target_department_id: number;
|
||||||
|
target_department_name?: string;
|
||||||
|
target_contractor_id?: number;
|
||||||
|
target_contractor_name?: string;
|
||||||
|
swap_reason: SwapReason;
|
||||||
|
reason_details?: string;
|
||||||
|
work_completion_percentage: number;
|
||||||
|
swap_date: string;
|
||||||
|
swapped_by_id: number;
|
||||||
|
swapped_by_name?: string;
|
||||||
|
status: SwapStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'SuperAdmin' | 'Supervisor' | 'Contractor' | 'Employee';
|
role: "SuperAdmin" | "Supervisor" | "Contractor" | "Employee";
|
||||||
department_id?: number;
|
department_id?: number;
|
||||||
contractor_id?: number;
|
contractor_id?: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
@@ -13,13 +13,11 @@ export interface User {
|
|||||||
sub_department_id?: number;
|
sub_department_id?: number;
|
||||||
sub_department_name?: string;
|
sub_department_name?: string;
|
||||||
primary_activity?: string;
|
primary_activity?: string;
|
||||||
// Common fields for Employee and Contractor
|
|
||||||
phone_number?: string;
|
phone_number?: string;
|
||||||
aadhar_number?: string;
|
aadhar_number?: string;
|
||||||
bank_account_number?: string;
|
bank_account_number?: string;
|
||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
bank_ifsc?: string;
|
bank_ifsc?: string;
|
||||||
// Contractor-specific fields
|
|
||||||
contractor_agreement_number?: string;
|
contractor_agreement_number?: string;
|
||||||
pf_number?: string;
|
pf_number?: string;
|
||||||
esic_number?: string;
|
esic_number?: string;
|
||||||
@@ -45,7 +43,7 @@ export interface Activity {
|
|||||||
id: number;
|
id: number;
|
||||||
sub_department_id: number;
|
sub_department_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
unit_of_measurement: 'Per Bag' | 'Fixed Rate-Per Person';
|
unit_of_measurement: "Per Bag" | "Fixed Rate-Per Person";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
sub_department_name?: string;
|
sub_department_name?: string;
|
||||||
department_id?: number;
|
department_id?: number;
|
||||||
@@ -60,7 +58,7 @@ export interface WorkAllocation {
|
|||||||
sub_department_id?: number;
|
sub_department_id?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
assigned_date: string;
|
assigned_date: string;
|
||||||
status: 'Pending' | 'InProgress' | 'Completed' | 'Cancelled';
|
status: "Pending" | "InProgress" | "Completed" | "Cancelled";
|
||||||
completion_date?: string;
|
completion_date?: string;
|
||||||
rate?: number;
|
rate?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -73,7 +71,12 @@ export interface WorkAllocation {
|
|||||||
department_name?: string;
|
department_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AttendanceStatus = 'CheckedIn' | 'CheckedOut' | 'Absent' | 'HalfDay' | 'Late';
|
export type AttendanceStatus =
|
||||||
|
| "CheckedIn"
|
||||||
|
| "CheckedOut"
|
||||||
|
| "Absent"
|
||||||
|
| "HalfDay"
|
||||||
|
| "Late";
|
||||||
|
|
||||||
export interface Attendance {
|
export interface Attendance {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -93,8 +96,8 @@ export interface Attendance {
|
|||||||
contractor_name?: string;
|
contractor_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SwapReason = 'LeftWork' | 'Sick' | 'FinishedEarly' | 'Other';
|
export type SwapReason = "LeftWork" | "Sick" | "FinishedEarly" | "Other";
|
||||||
export type SwapStatus = 'Active' | 'Completed' | 'Cancelled';
|
export type SwapStatus = "Active" | "Completed" | "Cancelled";
|
||||||
|
|
||||||
export interface EmployeeSwap {
|
export interface EmployeeSwap {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
interface AllocationData {
|
interface AllocationData {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -44,16 +44,20 @@ interface WorkReportData {
|
|||||||
export const exportWorkReportToXLSX = (
|
export const exportWorkReportToXLSX = (
|
||||||
allocations: AllocationData[],
|
allocations: AllocationData[],
|
||||||
departmentName: string,
|
departmentName: string,
|
||||||
_dateRange: { startDate: string; endDate: string }
|
_dateRange: { startDate: string; endDate: string },
|
||||||
) => {
|
) => {
|
||||||
// Group allocations by work (activity + sub_department) and date
|
// Group allocations by work (activity + sub_department) and date
|
||||||
const workDataMap = new Map<string, WorkReportData>();
|
const workDataMap = new Map<string, WorkReportData>();
|
||||||
const allDates = new Set<string>();
|
const allDates = new Set<string>();
|
||||||
|
|
||||||
allocations.forEach(allocation => {
|
allocations.forEach((allocation) => {
|
||||||
const workKey = `${allocation.sub_department_name || ''} ${allocation.activity || 'Standard'}`.trim();
|
const workKey = `${allocation.sub_department_name || ""} ${
|
||||||
const date = allocation.assigned_date ? new Date(allocation.assigned_date).getDate().toString() : '';
|
allocation.activity || "Standard"
|
||||||
|
}`.trim();
|
||||||
|
const date = allocation.assigned_date
|
||||||
|
? new Date(allocation.assigned_date).getDate().toString()
|
||||||
|
: "";
|
||||||
|
|
||||||
if (date) {
|
if (date) {
|
||||||
allDates.add(date);
|
allDates.add(date);
|
||||||
}
|
}
|
||||||
@@ -68,14 +72,15 @@ export const exportWorkReportToXLSX = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workData = workDataMap.get(workKey)!;
|
const workData = workDataMap.get(workKey)!;
|
||||||
|
|
||||||
if (!workData.dates[date]) {
|
if (!workData.dates[date]) {
|
||||||
workData.dates[date] = { bag: 0, rate: 0, total: 0 };
|
workData.dates[date] = { bag: 0, rate: 0, total: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const bag = parseFloat(String(allocation.units)) || 0;
|
const bag = parseFloat(String(allocation.units)) || 0;
|
||||||
const rate = parseFloat(String(allocation.rate)) || 0;
|
const rate = parseFloat(String(allocation.rate)) || 0;
|
||||||
const total = parseFloat(String(allocation.total_amount)) || (bag * rate) || rate;
|
const total = parseFloat(String(allocation.total_amount)) || (bag * rate) ||
|
||||||
|
rate;
|
||||||
|
|
||||||
workData.dates[date].bag += bag;
|
workData.dates[date].bag += bag;
|
||||||
workData.dates[date].rate = rate; // Use latest rate
|
workData.dates[date].rate = rate; // Use latest rate
|
||||||
@@ -85,61 +90,66 @@ export const exportWorkReportToXLSX = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Sort dates numerically
|
// Sort dates numerically
|
||||||
const sortedDates = Array.from(allDates).sort((a, b) => parseInt(a) - parseInt(b));
|
const sortedDates = Array.from(allDates).sort((a, b) =>
|
||||||
|
parseInt(a) - parseInt(b)
|
||||||
|
);
|
||||||
|
|
||||||
// Create workbook and worksheet
|
// Create workbook and worksheet
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = XLSX.utils.book_new();
|
||||||
const wsData: (string | number | null)[][] = [];
|
const wsData: (string | number | null)[][] = [];
|
||||||
|
|
||||||
// Row 1: DATE header with merged cells for each date
|
// Row 1: DATE header with merged cells for each date
|
||||||
const dateHeaderRow: (string | number | null)[] = ['', 'DATE'];
|
const dateHeaderRow: (string | number | null)[] = ["", "DATE"];
|
||||||
sortedDates.forEach(date => {
|
sortedDates.forEach((date) => {
|
||||||
dateHeaderRow.push(date, '', ''); // Each date spans 3 columns (Bag, Rate, Total)
|
dateHeaderRow.push(date, "", ""); // Each date spans 3 columns (Bag, Rate, Total)
|
||||||
});
|
});
|
||||||
dateHeaderRow.push('', 'Total', '', '', 'Total-As per Standered', '', '');
|
dateHeaderRow.push("", "Total", "", "", "Total-As per Standered", "", "");
|
||||||
wsData.push(dateHeaderRow);
|
wsData.push(dateHeaderRow);
|
||||||
|
|
||||||
// Row 2: WORK and Bag/Rate/Total sub-headers
|
// Row 2: WORK and Bag/Rate/Total sub-headers
|
||||||
const subHeaderRow: (string | number | null)[] = ['', 'WORK'];
|
const subHeaderRow: (string | number | null)[] = ["", "WORK"];
|
||||||
sortedDates.forEach(() => {
|
sortedDates.forEach(() => {
|
||||||
subHeaderRow.push('Bag', 'Rate', 'Total');
|
subHeaderRow.push("Bag", "Rate", "Total");
|
||||||
});
|
});
|
||||||
subHeaderRow.push('', 'Bag', 'Rate', 'Total', 'Bag', 'Rate', 'Total');
|
subHeaderRow.push("", "Bag", "Rate", "Total", "Bag", "Rate", "Total");
|
||||||
wsData.push(subHeaderRow);
|
wsData.push(subHeaderRow);
|
||||||
|
|
||||||
// Row 3: Department header (yellow background)
|
// Row 3: Department header (yellow background)
|
||||||
const deptHeaderRow: (string | number | null)[] = ['', `${departmentName.toUpperCase()} Department`];
|
const deptHeaderRow: (string | number | null)[] = [
|
||||||
|
"",
|
||||||
|
`${departmentName.toUpperCase()} Department`,
|
||||||
|
];
|
||||||
const deptHeaderCols = sortedDates.length * 3 + 7;
|
const deptHeaderCols = sortedDates.length * 3 + 7;
|
||||||
for (let col = 0; col < deptHeaderCols; col++) {
|
for (let col = 0; col < deptHeaderCols; col++) {
|
||||||
deptHeaderRow.push('');
|
deptHeaderRow.push("");
|
||||||
}
|
}
|
||||||
wsData.push(deptHeaderRow);
|
wsData.push(deptHeaderRow);
|
||||||
|
|
||||||
// Data rows for each work item
|
// Data rows for each work item
|
||||||
const workDataArray = Array.from(workDataMap.values());
|
const workDataArray = Array.from(workDataMap.values());
|
||||||
|
|
||||||
workDataArray.forEach((workData, index) => {
|
workDataArray.forEach((workData, index) => {
|
||||||
const dataRow: (string | number | null)[] = [index + 1, workData.work];
|
const dataRow: (string | number | null)[] = [index + 1, workData.work];
|
||||||
|
|
||||||
sortedDates.forEach(date => {
|
sortedDates.forEach((date) => {
|
||||||
const dateData = workData.dates[date] || { bag: 0, rate: 0, total: 0 };
|
const dateData = workData.dates[date] || { bag: 0, rate: 0, total: 0 };
|
||||||
dataRow.push(
|
dataRow.push(
|
||||||
dateData.bag || '',
|
dateData.bag || "",
|
||||||
dateData.rate || '',
|
dateData.rate || "",
|
||||||
dateData.total || ''
|
dateData.total || "",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Total columns
|
// Total columns
|
||||||
dataRow.push(''); // Empty column
|
dataRow.push(""); // Empty column
|
||||||
dataRow.push(workData.totalBag || '');
|
dataRow.push(workData.totalBag || "");
|
||||||
dataRow.push(''); // Rate for total (could be average)
|
dataRow.push(""); // Rate for total (could be average)
|
||||||
dataRow.push(workData.totalAmount || '');
|
dataRow.push(workData.totalAmount || "");
|
||||||
|
|
||||||
// Standard columns (placeholder - would need standard rates data)
|
// Standard columns (placeholder - would need standard rates data)
|
||||||
dataRow.push('');
|
dataRow.push("");
|
||||||
dataRow.push('');
|
dataRow.push("");
|
||||||
dataRow.push('');
|
dataRow.push("");
|
||||||
|
|
||||||
wsData.push(dataRow);
|
wsData.push(dataRow);
|
||||||
});
|
});
|
||||||
@@ -148,33 +158,36 @@ export const exportWorkReportToXLSX = (
|
|||||||
wsData.push([]);
|
wsData.push([]);
|
||||||
|
|
||||||
// Sub Total row
|
// Sub Total row
|
||||||
const subTotalRow: (string | number | null)[] = ['', 'Sub Total'];
|
const subTotalRow: (string | number | null)[] = ["", "Sub Total"];
|
||||||
|
|
||||||
// Calculate totals for each date
|
// Calculate totals for each date
|
||||||
sortedDates.forEach(date => {
|
sortedDates.forEach((date) => {
|
||||||
let dateBagTotal = 0;
|
let dateBagTotal = 0;
|
||||||
let dateTotalAmount = 0;
|
let dateTotalAmount = 0;
|
||||||
workDataArray.forEach(workData => {
|
workDataArray.forEach((workData) => {
|
||||||
const dateData = workData.dates[date];
|
const dateData = workData.dates[date];
|
||||||
if (dateData) {
|
if (dateData) {
|
||||||
dateBagTotal += dateData.bag;
|
dateBagTotal += dateData.bag;
|
||||||
dateTotalAmount += dateData.total;
|
dateTotalAmount += dateData.total;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
subTotalRow.push(dateBagTotal || '', '', dateTotalAmount || '');
|
subTotalRow.push(dateBagTotal || "", "", dateTotalAmount || "");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Grand totals
|
// Grand totals
|
||||||
const grandTotalBag = workDataArray.reduce((sum, w) => sum + w.totalBag, 0);
|
const grandTotalBag = workDataArray.reduce((sum, w) => sum + w.totalBag, 0);
|
||||||
const grandTotalAmount = workDataArray.reduce((sum, w) => sum + w.totalAmount, 0);
|
const grandTotalAmount = workDataArray.reduce(
|
||||||
|
(sum, w) => sum + w.totalAmount,
|
||||||
subTotalRow.push('');
|
0,
|
||||||
subTotalRow.push(grandTotalBag || '');
|
);
|
||||||
subTotalRow.push('');
|
|
||||||
subTotalRow.push(grandTotalAmount || '');
|
subTotalRow.push("");
|
||||||
subTotalRow.push('');
|
subTotalRow.push(grandTotalBag || "");
|
||||||
subTotalRow.push('');
|
subTotalRow.push("");
|
||||||
subTotalRow.push(grandTotalAmount || ''); // Standard total same as actual for now
|
subTotalRow.push(grandTotalAmount || "");
|
||||||
|
subTotalRow.push("");
|
||||||
|
subTotalRow.push("");
|
||||||
|
subTotalRow.push(grandTotalAmount || ""); // Standard total same as actual for now
|
||||||
|
|
||||||
wsData.push(subTotalRow);
|
wsData.push(subTotalRow);
|
||||||
|
|
||||||
@@ -183,37 +196,37 @@ export const exportWorkReportToXLSX = (
|
|||||||
|
|
||||||
// Set column widths
|
// Set column widths
|
||||||
const colWidths: { wch: number }[] = [
|
const colWidths: { wch: number }[] = [
|
||||||
{ wch: 4 }, // A - Row number
|
{ wch: 4 }, // A - Row number
|
||||||
{ wch: 35 }, // B - Work name
|
{ wch: 35 }, // B - Work name
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add widths for date columns
|
// Add widths for date columns
|
||||||
sortedDates.forEach(() => {
|
sortedDates.forEach(() => {
|
||||||
colWidths.push({ wch: 8 }); // Bag
|
colWidths.push({ wch: 8 }); // Bag
|
||||||
colWidths.push({ wch: 6 }); // Rate
|
colWidths.push({ wch: 6 }); // Rate
|
||||||
colWidths.push({ wch: 10 }); // Total
|
colWidths.push({ wch: 10 }); // Total
|
||||||
});
|
});
|
||||||
|
|
||||||
// Total columns
|
// Total columns
|
||||||
colWidths.push({ wch: 3 }); // Empty
|
colWidths.push({ wch: 3 }); // Empty
|
||||||
colWidths.push({ wch: 10 }); // Total Bag
|
colWidths.push({ wch: 10 }); // Total Bag
|
||||||
colWidths.push({ wch: 6 }); // Total Rate
|
colWidths.push({ wch: 6 }); // Total Rate
|
||||||
colWidths.push({ wch: 12 }); // Total Amount
|
colWidths.push({ wch: 12 }); // Total Amount
|
||||||
colWidths.push({ wch: 10 }); // Standard Bag
|
colWidths.push({ wch: 10 }); // Standard Bag
|
||||||
colWidths.push({ wch: 6 }); // Standard Rate
|
colWidths.push({ wch: 6 }); // Standard Rate
|
||||||
colWidths.push({ wch: 12 }); // Standard Total
|
colWidths.push({ wch: 12 }); // Standard Total
|
||||||
|
|
||||||
ws['!cols'] = colWidths;
|
ws["!cols"] = colWidths;
|
||||||
|
|
||||||
// Merge cells for DATE headers
|
// Merge cells for DATE headers
|
||||||
const merges: XLSX.Range[] = [];
|
const merges: XLSX.Range[] = [];
|
||||||
|
|
||||||
// Merge DATE header cells for each date (row 1)
|
// Merge DATE header cells for each date (row 1)
|
||||||
let colIndex = 2; // Start after row number and WORK columns
|
let colIndex = 2; // Start after row number and WORK columns
|
||||||
sortedDates.forEach(() => {
|
sortedDates.forEach(() => {
|
||||||
merges.push({
|
merges.push({
|
||||||
s: { r: 0, c: colIndex },
|
s: { r: 0, c: colIndex },
|
||||||
e: { r: 0, c: colIndex + 2 }
|
e: { r: 0, c: colIndex + 2 },
|
||||||
});
|
});
|
||||||
colIndex += 3;
|
colIndex += 3;
|
||||||
});
|
});
|
||||||
@@ -221,28 +234,30 @@ export const exportWorkReportToXLSX = (
|
|||||||
// Merge Total header
|
// Merge Total header
|
||||||
merges.push({
|
merges.push({
|
||||||
s: { r: 0, c: colIndex + 1 },
|
s: { r: 0, c: colIndex + 1 },
|
||||||
e: { r: 0, c: colIndex + 3 }
|
e: { r: 0, c: colIndex + 3 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge "Total-As per Standered" header
|
// Merge "Total-As per Standered" header
|
||||||
merges.push({
|
merges.push({
|
||||||
s: { r: 0, c: colIndex + 4 },
|
s: { r: 0, c: colIndex + 4 },
|
||||||
e: { r: 0, c: colIndex + 6 }
|
e: { r: 0, c: colIndex + 6 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge department header row
|
// Merge department header row
|
||||||
merges.push({
|
merges.push({
|
||||||
s: { r: 2, c: 1 },
|
s: { r: 2, c: 1 },
|
||||||
e: { r: 2, c: colIndex + 6 }
|
e: { r: 2, c: colIndex + 6 },
|
||||||
});
|
});
|
||||||
|
|
||||||
ws['!merges'] = merges;
|
ws["!merges"] = merges;
|
||||||
|
|
||||||
// Add worksheet to workbook
|
// Add worksheet to workbook
|
||||||
XLSX.utils.book_append_sheet(wb, ws, 'Work Report');
|
XLSX.utils.book_append_sheet(wb, ws, "Work Report");
|
||||||
|
|
||||||
// Generate filename
|
// Generate filename
|
||||||
const filename = `work_report_${departmentName.toLowerCase().replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
|
const filename = `work_report_${
|
||||||
|
departmentName.toLowerCase().replace(/\s+/g, "_")
|
||||||
|
}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
|
||||||
// Write and download
|
// Write and download
|
||||||
XLSX.writeFile(wb, filename);
|
XLSX.writeFile(wb, filename);
|
||||||
@@ -253,27 +268,31 @@ export const exportWorkReportToXLSX = (
|
|||||||
*/
|
*/
|
||||||
export const exportAllocationsToXLSX = (
|
export const exportAllocationsToXLSX = (
|
||||||
allocations: AllocationData[],
|
allocations: AllocationData[],
|
||||||
filename?: string
|
filename?: string,
|
||||||
) => {
|
) => {
|
||||||
if (allocations.length === 0) {
|
if (allocations.length === 0) {
|
||||||
alert('No data to export');
|
alert("No data to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform data for export
|
// Transform data for export
|
||||||
const exportData = allocations.map((a, index) => ({
|
const exportData = allocations.map((a, index) => ({
|
||||||
'S.No': index + 1,
|
"S.No": index + 1,
|
||||||
'Employee Name': a.employee_name || '',
|
"Employee Name": a.employee_name || "",
|
||||||
'Contractor': a.contractor_name || '',
|
"Contractor": a.contractor_name || "",
|
||||||
'Department': a.department_name || '',
|
"Department": a.department_name || "",
|
||||||
'Sub-Department': a.sub_department_name || '',
|
"Sub-Department": a.sub_department_name || "",
|
||||||
'Activity': a.activity || 'Standard',
|
"Activity": a.activity || "Standard",
|
||||||
'Assigned Date': a.assigned_date ? new Date(a.assigned_date).toLocaleDateString() : '',
|
"Assigned Date": a.assigned_date
|
||||||
'Completion Date': a.completion_date ? new Date(a.completion_date).toLocaleDateString() : '',
|
? new Date(a.assigned_date).toLocaleDateString()
|
||||||
'Rate': a.rate || 0,
|
: "",
|
||||||
'Units': a.units || '',
|
"Completion Date": a.completion_date
|
||||||
'Total Amount': a.total_amount || a.rate || 0,
|
? new Date(a.completion_date).toLocaleDateString()
|
||||||
'Status': a.status || '',
|
: "",
|
||||||
|
"Rate": a.rate || 0,
|
||||||
|
"Units": a.units || "",
|
||||||
|
"Total Amount": a.total_amount || a.rate || 0,
|
||||||
|
"Status": a.status || "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create workbook
|
// Create workbook
|
||||||
@@ -281,8 +300,8 @@ export const exportAllocationsToXLSX = (
|
|||||||
const ws = XLSX.utils.json_to_sheet(exportData);
|
const ws = XLSX.utils.json_to_sheet(exportData);
|
||||||
|
|
||||||
// Set column widths
|
// Set column widths
|
||||||
ws['!cols'] = [
|
ws["!cols"] = [
|
||||||
{ wch: 6 }, // S.No
|
{ wch: 6 }, // S.No
|
||||||
{ wch: 25 }, // Employee Name
|
{ wch: 25 }, // Employee Name
|
||||||
{ wch: 20 }, // Contractor
|
{ wch: 20 }, // Contractor
|
||||||
{ wch: 15 }, // Department
|
{ wch: 15 }, // Department
|
||||||
@@ -291,15 +310,16 @@ export const exportAllocationsToXLSX = (
|
|||||||
{ wch: 12 }, // Assigned Date
|
{ wch: 12 }, // Assigned Date
|
||||||
{ wch: 14 }, // Completion Date
|
{ wch: 14 }, // Completion Date
|
||||||
{ wch: 10 }, // Rate
|
{ wch: 10 }, // Rate
|
||||||
{ wch: 8 }, // Units
|
{ wch: 8 }, // Units
|
||||||
{ wch: 12 }, // Total Amount
|
{ wch: 12 }, // Total Amount
|
||||||
{ wch: 10 }, // Status
|
{ wch: 10 }, // Status
|
||||||
];
|
];
|
||||||
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, 'Allocations');
|
XLSX.utils.book_append_sheet(wb, ws, "Allocations");
|
||||||
|
|
||||||
// Generate filename
|
// Generate filename
|
||||||
const outputFilename = filename || `allocations_${new Date().toISOString().split('T')[0]}.xlsx`;
|
const outputFilename = filename ||
|
||||||
|
`allocations_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
|
||||||
// Write and download
|
// Write and download
|
||||||
XLSX.writeFile(wb, outputFilename);
|
XLSX.writeFile(wb, outputFilename);
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ export default {
|
|||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: true, // Allow access from any host
|
host: true, // Allow access from any host
|
||||||
allowedHosts: ['all'], // Allow all hosts
|
allowedHosts: ["all"], // Allow all hosts
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user