(Feat-Fix): Lots of fixes done, reporting system fixed, stricter types

This commit is contained in:
2025-12-19 18:48:05 +00:00
parent 01400ad4e1
commit 865e0bf00e
61 changed files with 10072 additions and 6645 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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

View File

@@ -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();

View File

@@ -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;

View File

@@ -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());

View File

@@ -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();
}; };
} }

View File

@@ -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 };
} }

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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",
}, },
}, },
) );

View File

@@ -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
View File

@@ -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": {

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -3,4 +3,4 @@ export default {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>;
}; };

View File

@@ -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}
/> />

View File

@@ -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>
);
}; };

View File

@@ -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 (

View 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;
};

View File

@@ -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);

View File

@@ -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,
}; };
}; };

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
); );
}; };

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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",
}); });
} }
} }

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -8,4 +8,4 @@ export default {
extend: {}, extend: {},
}, },
plugins: [], plugins: [],
} };

View File

@@ -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
}, },
}) });