Compare commits

...

10 Commits

88 changed files with 15292 additions and 6896 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)
@@ -61,7 +60,7 @@ npm run dev
### Docker Management ### Docker Management
```bash ```bash
# Stop MySQL # Stop MySQLclear
docker-compose down docker-compose down
# Stop and remove all data # Stop and remove all data

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.

82
activities.md Normal file
View File

@@ -0,0 +1,82 @@
# Activity Departments and Units of Measurement
## GROUNDNUT Department
| # | Activity Name | Sub-Department | Unit of Measurement |
| -- | ---------------------------------------------------------------------------------------------- | ---------------------------------------- | --------------------- |
| 1 | Mufali Aavak Katai (Groundnut Arrival Cutting) | 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 |
| 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 |
| 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 |
| 8 | Bardana Bandal Loading (Gunny Bundle Loading) | 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 |
| 11 | Pre Cleaner | Pre Cleaning | Fixed Rate-Per Person |
| 12 | Destoner | Destoner | Fixed Rate-Per Person |
| 13 | Water | Water | 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 |
| 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 |
| 18 | Dala - Chomu & Jaipur (Branch - Chomu & Jaipur) | Loading/Unloading | Per Bag |
## DANA Department
| # | Activity Name | Sub-Department | Unit of Measurement |
| -- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------- |
| 1 | Tulai Silai Loading (Weighing Stitching Loading) | Loading/Unloading | Per Bag |
| 2 | Dhaang se Loading (Loading from 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 |
| 5 | Dana Unloading/Dhaang (Grain Unloading/Stack) | 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 |
| 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 |
| 10 | Keep Katai Silai Kholkar (Hopper Cutting Opening Stitched) | 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 |
| 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 |
| 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 |
| 17 | Grading | Loading/Unloading | Per Bag |
| 18 | Destoner | Destoner | Fixed Rate-Per Person |
| 19 | Gravity | Gravity | Fixed Rate-Per Person |
| 20 | Tank | Tank | Fixed Rate-Per Person |
| 21 | Sortex | Sortex | Fixed Rate-Per Person |
| 22 | X-Ray | X-Ray | Fixed Rate-Per Person |
| 23 | Kachri (Waste) | Kachri | Fixed Rate-Per Person |
| 24 | Other Works | Other Works | Fixed Rate-Per Person |
## TUKDI Department
| # | Activity Name | Sub-Department | Unit of Measurement |
| -- | ----------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------- |
| 1 | Dana Loaning/Unloading (Grain Loading/Unloading) | 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 16 | Tank | Tank | Fixed Rate-Per Person |
| 17 | Grader (Machine) | Grader (Machine) | Fixed Rate-Per Person |
| 18 | Sortex | Sortex | Fixed Rate-Per Person |
| 19 | X-Ray | X-Ray | Fixed Rate-Per Person |
| 20 | Rejection | Rejection | Fixed Rate-Per Person |
| 21 | Store | Store | Fixed Rate-Per Person |
| 22 | Roster | Roster | Fixed Rate-Per Person |
| 23 | Blancher | Blancher | Fixed Rate-Per Person |
| 24 | Other Works | Other Works | Fixed Rate-Per Person |

View File

@@ -3,7 +3,7 @@ DB_HOST=localhost
DB_USER=root DB_USER=root
DB_PASSWORD=admin123 DB_PASSWORD=admin123
DB_NAME=work_allocation DB_NAME=work_allocation
DB_PORT=3306 DB_PORT=3307
# JWT Configuration - CHANGE IN PRODUCTION! # JWT Configuration - CHANGE IN PRODUCTION!
JWT_SECRET=work_alloc_jwt_secret_key_change_in_production_2024 JWT_SECRET=work_alloc_jwt_secret_key_change_in_production_2024

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";
@@ -10,6 +15,10 @@ import departmentRoutes from "./routes/departments.ts";
import workAllocationRoutes from "./routes/work-allocations.ts"; import workAllocationRoutes from "./routes/work-allocations.ts";
import attendanceRoutes from "./routes/attendance.ts"; import attendanceRoutes from "./routes/attendance.ts";
import contractorRateRoutes from "./routes/contractor-rates.ts"; import contractorRateRoutes from "./routes/contractor-rates.ts";
import employeeSwapRoutes from "./routes/employee-swaps.ts";
import reportRoutes from "./routes/reports.ts";
import standardRateRoutes from "./routes/standard-rates.ts";
import activityRoutes from "./routes/activities.ts";
// Initialize database connection // Initialize database connection
await db.connect(); await db.connect();
@@ -57,10 +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/work-allocations",
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

@@ -0,0 +1,228 @@
import { Router } from "@oak/oak";
import { db } from "../config/database.ts";
import { authenticateToken, getCurrentUser } from "../middleware/auth.ts";
const router = new Router();
interface Activity {
id: number;
sub_department_id: number;
name: string;
unit_of_measurement: string;
created_at: string;
sub_department_name?: string;
department_id?: number;
department_name?: string;
}
// Get all activities (with optional filters)
router.get("/", authenticateToken, async (ctx) => {
try {
const params = ctx.request.url.searchParams;
const subDepartmentId = params.get("subDepartmentId");
const departmentId = params.get("departmentId");
let query = `
SELECT a.id, a.sub_department_id, a.name, a.unit_of_measurement, a.created_at,
sd.name as sub_department_name,
sd.department_id,
d.name as department_name
FROM activities a
JOIN sub_departments sd ON a.sub_department_id = sd.id
JOIN departments d ON sd.department_id = d.id
WHERE 1=1
`;
const queryParams: unknown[] = [];
if (subDepartmentId) {
query += " AND a.sub_department_id = ?";
queryParams.push(subDepartmentId);
}
if (departmentId) {
query += " AND sd.department_id = ?";
queryParams.push(departmentId);
}
query += " ORDER BY d.name, sd.name, a.name";
const activities = await db.query<Activity[]>(query, queryParams);
ctx.response.body = activities;
} catch (error) {
console.error("Get activities error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Get activity by ID
router.get("/:id", authenticateToken, async (ctx) => {
try {
const activityId = ctx.params.id;
const activities = await db.query<Activity[]>(
`SELECT a.id, a.sub_department_id, a.name, a.unit_of_measurement, a.created_at,
sd.name as sub_department_name,
sd.department_id,
d.name as department_name
FROM activities a
JOIN sub_departments sd ON a.sub_department_id = sd.id
JOIN departments d ON sd.department_id = d.id
WHERE a.id = ?`,
[activityId],
);
if (activities.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Activity not found" };
return;
}
ctx.response.body = activities[0];
} catch (error) {
console.error("Get activity error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Create activity (SuperAdmin or Supervisor for their own department)
router.post("/", authenticateToken, async (ctx) => {
try {
const user = getCurrentUser(ctx);
const body = await ctx.request.body.json();
const { sub_department_id, name, unit_of_measurement } = body;
if (!sub_department_id || !name) {
ctx.response.status = 400;
ctx.response.body = { error: "Sub-department ID and name are required" };
return;
}
// Get the sub-department to check department ownership
const subDepts = await db.query<{ department_id: number }[]>(
"SELECT department_id FROM sub_departments WHERE id = ?",
[sub_department_id],
);
if (subDepts.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Sub-department not found" };
return;
}
const subDeptDepartmentId = subDepts[0].department_id;
// Check authorization
if (
user.role === "Supervisor" && user.departmentId !== subDeptDepartmentId
) {
ctx.response.status = 403;
ctx.response.body = {
error: "You can only create activities for your own department",
};
return;
}
if (user.role !== "SuperAdmin" && user.role !== "Supervisor") {
ctx.response.status = 403;
ctx.response.body = { error: "Unauthorized" };
return;
}
const result = await db.execute(
"INSERT INTO activities (sub_department_id, name, unit_of_measurement) VALUES (?, ?, ?)",
[sub_department_id, name, unit_of_measurement || "Per Bag"],
);
ctx.response.status = 201;
ctx.response.body = {
id: result.insertId,
message: "Activity created successfully",
};
} catch (error) {
const err = error as { code?: string };
if (err.code === "ER_DUP_ENTRY") {
ctx.response.status = 400;
ctx.response.body = {
error: "Activity already exists in this sub-department",
};
return;
}
console.error("Create activity error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Update activity
router.put("/:id", authenticateToken, async (ctx) => {
try {
const activityId = ctx.params.id;
const body = await ctx.request.body.json();
const { name, unit_of_measurement } = body;
await db.execute(
"UPDATE activities SET name = ?, unit_of_measurement = ? WHERE id = ?",
[name, unit_of_measurement, activityId],
);
ctx.response.body = { message: "Activity updated successfully" };
} catch (error) {
console.error("Update activity error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Delete activity (SuperAdmin or Supervisor for their own department)
router.delete("/:id", authenticateToken, async (ctx) => {
try {
const user = getCurrentUser(ctx);
const activityId = ctx.params.id;
// Get the activity and its sub-department to check department ownership
const activities = await db.query<Activity[]>(
`SELECT a.*, sd.department_id
FROM activities a
JOIN sub_departments sd ON a.sub_department_id = sd.id
WHERE a.id = ?`,
[activityId],
);
if (activities.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Activity not found" };
return;
}
const activity = activities[0] as Activity & { department_id: number };
// Check authorization
if (
user.role === "Supervisor" && user.departmentId !== activity.department_id
) {
ctx.response.status = 403;
ctx.response.body = {
error: "You can only delete activities from your own department",
};
return;
}
if (user.role !== "SuperAdmin" && user.role !== "Supervisor") {
ctx.response.status = 403;
ctx.response.body = { error: "Unauthorized" };
return;
}
await db.execute("DELETE FROM activities WHERE id = ?", [activityId]);
ctx.response.body = { message: "Activity deleted successfully" };
} catch (error) {
console.error("Delete activity error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
export default router;

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 } 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,56 +122,65 @@ 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;
} if (!employeeId || !workDate) {
ctx.response.status = 400;
// Verify employee exists ctx.response.body = { error: "Employee ID and work date required" };
let employeeQuery = "SELECT * FROM users WHERE id = ? AND role = ?"; return;
const employeeParams: unknown[] = [employeeId, "Employee"]; }
if (currentUser.role === "Supervisor") { // Verify user exists and is Employee or Contractor
employeeQuery += " AND department_id = ?"; let userQuery = "SELECT * FROM users WHERE id = ? AND role IN ('Employee', 'Contractor')";
employeeParams.push(currentUser.departmentId); const userParams: unknown[] = [employeeId];
}
if (currentUser.role === "Supervisor") {
const employees = await db.query<User[]>(employeeQuery, employeeParams); userQuery += " AND department_id = ?";
userParams.push(currentUser.departmentId);
if (employees.length === 0) { }
ctx.response.status = 403;
ctx.response.body = { error: "Employee not found or not in your department" }; const users = await db.query<User[]>(userQuery, userParams);
return;
} if (users.length === 0) {
ctx.response.status = 403;
// Check if already checked in today ctx.response.body = {
const existing = await db.query<Attendance[]>( error: "User not found, not an Employee/Contractor, or not in your department",
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?", };
[employeeId, workDate, "CheckedIn"] return;
); }
if (existing.length > 0) { // Check if there's an active check-in (not yet checked out) for today
ctx.response.status = 400; const activeCheckIn = await db.query<Attendance[]>(
ctx.response.body = { error: "Employee already checked in today" }; "SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = 'CheckedIn'",
return; [employeeId, workDate],
} );
const checkInTime = new Date().toISOString().slice(0, 19).replace("T", " "); if (activeCheckIn.length > 0) {
ctx.response.status = 400;
const result = await db.execute( ctx.response.body = { error: "User has an active check-in. Please check out first before checking in again." };
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)", return;
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"] }
);
const checkInTime = new Date().toISOString().slice(0, 19).replace(
const newRecord = await db.query<Attendance[]>( "T",
`SELECT a.*, " ",
);
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, 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,
@@ -165,57 +191,66 @@ router.post("/check-in", authenticateToken, authorize("Supervisor", "SuperAdmin"
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] [result.insertId],
); );
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = newRecord[0]; ctx.response.body = newRecord[0];
} catch (error) { } catch (error) {
console.error("Check in error:", error); console.error("Check in error:", error);
ctx.response.status = 500; ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" }; ctx.response.body = { error: "Internal server error" };
} }
}); },
);
// Check out employee (Supervisor or SuperAdmin) // Check out employee (Supervisor or SuperAdmin)
router.post("/check-out", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => { router.post(
try { "/check-out",
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;
} if (!employeeId || !workDate) {
ctx.response.status = 400;
// Find the check-in record ctx.response.body = { error: "Employee ID and work date required" };
let query = "SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?"; return;
const params: unknown[] = [employeeId, workDate, "CheckedIn"]; }
if (currentUser.role === "Supervisor") { // Find the check-in record
query += " AND supervisor_id = ?"; let query =
params.push(currentUser.id); "SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?";
} const params: unknown[] = [employeeId, workDate, "CheckedIn"];
const records = await db.query<Attendance[]>(query, params); if (currentUser.role === "Supervisor") {
query += " AND supervisor_id = ?";
if (records.length === 0) { params.push(currentUser.id);
ctx.response.status = 404; }
ctx.response.body = { error: "No check-in record found for today" };
return; const records = await db.query<Attendance[]>(query, params);
}
if (records.length === 0) {
const checkOutTime = new Date().toISOString().slice(0, 19).replace("T", " "); ctx.response.status = 404;
ctx.response.body = { error: "No check-in record found for today" };
await db.execute( return;
"UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?", }
[checkOutTime, "CheckedOut", records[0].id]
); const checkOutTime = new Date().toISOString().slice(0, 19).replace(
"T",
const updatedRecord = await db.query<Attendance[]>( " ",
`SELECT a.*, );
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, 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,
@@ -226,27 +261,193 @@ router.post("/check-out", authenticateToken, authorize("Supervisor", "SuperAdmin
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 = ?`,
[records[0].id] [records[0].id],
); );
ctx.response.body = updatedRecord[0]; ctx.response.body = updatedRecord[0];
} catch (error) { } catch (error) {
console.error("Check out error:", error); console.error("Check out 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 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,
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 = ?`,
[existing[0].id],
);
ctx.response.body = updatedRecord[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" };
}
},
);
// 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,
@@ -257,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,10 +1,23 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { hash, compare } 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";
import { authenticateToken, generateToken, getCurrentUser } from "../middleware/auth.ts";
import { sanitizeInput, isValidEmail, isStrongPassword } from "../middleware/security.ts"; async function hashPassword(password: string): Promise<string> {
import type { User, LoginRequest, ChangePasswordRequest } from "../types/index.ts"; const salt = await genSalt(config.BCRYPT_ROUNDS);
return await hash(password, salt);
}
import {
authenticateToken,
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();
@@ -13,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,
@@ -55,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,
@@ -74,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);
@@ -100,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);
@@ -121,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 hash(newPassword, config.BCRYPT_ROUNDS); 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,241 +1,319 @@
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,
d.name as department_name d.name as department_name,
a.unit_of_measurement
FROM contractor_rates cr FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id JOIN users u ON cr.contractor_id = u.id
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 departments d ON sd.department_id = d.id LEFT JOIN departments d ON sd.department_id = d.id
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,
a.unit_of_measurement
FROM contractor_rates cr FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id JOIN users u ON cr.contractor_id = u.id
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
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
FROM contractor_rates cr FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id JOIN users u ON cr.contractor_id = u.id
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
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
FROM contractor_rates cr FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id JOIN users u ON cr.contractor_id = u.id
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
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,96 +48,254 @@ 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(
"/",
authenticateToken,
authorize("SuperAdmin"),
async (ctx: Context) => {
try {
const body = await ctx.request.body.json() as { name: string };
const { name } = body;
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" };
}
},
);
// Create sub-department (SuperAdmin or Supervisor for their own department)
router.post("/sub-departments", authenticateToken, async (ctx: Context) => {
try { try {
const body = await ctx.request.body.json() as { name: string }; const user = getCurrentUser(ctx);
const { name } = body; const body = await ctx.request.body.json() as {
department_id: number;
if (!name) { name: string;
};
const { department_id, name } = body;
if (!name || !department_id) {
ctx.response.status = 400; ctx.response.status = 400;
ctx.response.body = { error: "Department name required" }; ctx.response.body = { error: "Department ID and name are required" };
return; return;
} }
// Check authorization
if (user.role === "Supervisor" && user.departmentId !== department_id) {
ctx.response.status = 403;
ctx.response.body = {
error: "You can only create sub-departments for your own department",
};
return;
}
if (user.role !== "SuperAdmin" && user.role !== "Supervisor") {
ctx.response.status = 403;
ctx.response.body = { error: "Unauthorized" };
return;
}
const sanitizedName = sanitizeInput(name); const sanitizedName = sanitizeInput(name);
const result = await db.execute( const result = await db.execute(
"INSERT INTO departments (name) VALUES (?)", "INSERT INTO sub_departments (department_id, name) VALUES (?, ?)",
[sanitizedName] [department_id, sanitizedName],
); );
const newDepartment = await db.query<Department[]>( const newSubDepartment = await db.query<SubDepartment[]>(
"SELECT * FROM departments WHERE id = ?", "SELECT * FROM sub_departments WHERE id = ?",
[result.insertId] [result.insertId],
); );
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = newDepartment[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: "Department already exists" }; ctx.response.body = {
error: "Sub-department already exists in this department",
};
return; return;
} }
console.error("Create department error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Create sub-department (SuperAdmin only)
router.post("/:id/sub-departments", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
try {
const deptId = ctx.params.id;
const body = await ctx.request.body.json() as { name: string; primaryActivity: string };
const { name, primaryActivity } = body;
if (!name || !primaryActivity) {
ctx.response.status = 400;
ctx.response.body = { error: "Name and primary activity required" };
return;
}
const sanitizedName = sanitizeInput(name);
const sanitizedActivity = sanitizeInput(primaryActivity);
const result = await db.execute(
"INSERT INTO sub_departments (department_id, name, primary_activity) VALUES (?, ?, ?)",
[deptId, sanitizedName, sanitizedActivity]
);
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) {
console.error("Create sub-department error:", error); console.error("Create sub-department 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 sub-department (SuperAdmin or Supervisor for their own department)
router.delete(
"/sub-departments/:id",
authenticateToken,
async (ctx: RouterContext<"/sub-departments/:id">) => {
try {
const user = getCurrentUser(ctx);
const subDeptId = ctx.params.id;
// Get the sub-department to check department ownership
const subDepts = await db.query<SubDepartment[]>(
"SELECT * FROM sub_departments WHERE id = ?",
[subDeptId],
);
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" };
}
},
);
// Legacy route for creating sub-department under specific department (SuperAdmin only)
router.post(
"/:id/sub-departments",
authenticateToken,
authorize("SuperAdmin"),
async (ctx: RouterContext<"/:id/sub-departments">) => {
try {
const deptId: string | number = ctx.params.id;
const body = await ctx.request.body.json() as { name: string };
const { name } = body;
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" };
}
},
);
export default router; export default router;

View File

@@ -0,0 +1,416 @@
import { Router, type RouterContext, type State } from "@oak/oak";
import { db } from "../config/database.ts";
import {
authenticateToken,
authorize,
getCurrentUser,
} from "../middleware/auth.ts";
import type { CreateSwapRequest, EmployeeSwap, User } from "../types/index.ts";
const router = new Router();
// Get all employee swaps (SuperAdmin only)
router.get(
"/",
authenticateToken,
authorize("SuperAdmin"),
async (
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
) => {
try {
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.*,
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 1=1
`;
const queryParams: unknown[] = [];
if (status) {
query += " AND es.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" };
}
},
);
// Get swap by ID
router.get(
"/:id",
authenticateToken,
authorize("SuperAdmin"),
async (
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,
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],
);
if (swaps.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Swap record not found" };
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" };
}
},
);
// Create new employee swap (SuperAdmin only)
router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CreateSwapRequest;
const {
employeeId,
targetDepartmentId,
targetContractorId,
swapReason,
reasonDetails,
workCompletionPercentage,
swapDate,
} = body;
// Validate required fields
if (!employeeId || !targetDepartmentId || !swapReason || !swapDate) {
ctx.response.status = 400;
ctx.response.body = {
error:
"Employee ID, target department, swap reason, and swap date are required",
};
return;
}
// Validate swap reason
const validReasons = ["LeftWork", "Sick", "FinishedEarly", "Other"];
if (!validReasons.includes(swapReason)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid swap reason" };
return;
}
// Get employee's current department and contractor
const employees = await db.query<User[]>(
"SELECT * FROM users WHERE id = ? AND role = 'Employee'",
[employeeId],
);
if (employees.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Employee not found" };
return;
}
const employee = employees[0];
if (!employee.department_id) {
ctx.response.status = 400;
ctx.response.body = { error: "Employee has no current department" };
return;
}
// Check if there's already an active swap for this employee
const activeSwaps = await db.query<EmployeeSwap[]>(
"SELECT * FROM employee_swaps WHERE employee_id = ? AND status = 'Active'",
[employeeId],
);
if (activeSwaps.length > 0) {
ctx.response.status = 400;
ctx.response.body = {
error:
"Employee already has an active swap. Complete or cancel it first.",
};
return;
}
// Use transaction to ensure both operations succeed or fail together
const newSwap = await db.transaction(async (connection) => {
// Create the swap record
const [insertResult] = await connection.execute(
`INSERT INTO employee_swaps
(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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'Active')`,
[
employeeId,
employee.department_id,
targetDepartmentId,
employee.contractor_id || null,
targetContractorId || null,
swapReason,
reasonDetails || null,
workCompletionPercentage || 0,
swapDate,
currentUser.id,
],
);
const swapInsertId = (insertResult as { insertId: number }).insertId;
if (!swapInsertId) {
throw new Error("Failed to create swap record");
}
// Update the employee's department and contractor
// If no target contractor specified, keep the original contractor
const newContractorId = targetContractorId || employee.contractor_id || null;
const [updateResult] = await connection.execute(
"UPDATE users SET department_id = ?, contractor_id = ? WHERE id = ?",
[targetDepartmentId, newContractorId, employeeId],
);
const affectedRows =
(updateResult as { affectedRows: number }).affectedRows;
if (affectedRows === 0) {
throw new Error("Failed to update employee department");
}
// Fetch the created 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 = ?`,
[swapInsertId],
);
return (swapRows as EmployeeSwap[])[0];
});
ctx.response.status = 201;
ctx.response.body = newSwap;
} catch (error) {
console.error("Create swap error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Complete a swap (return employee to original department)
router.put(
"/:id/complete",
authenticateToken,
authorize("SuperAdmin"),
async (ctx) => {
try {
const swapId = ctx.params.id;
// Get the swap record
const swaps = await db.query<EmployeeSwap[]>(
"SELECT * FROM employee_swaps WHERE id = ? AND status = 'Active'",
[swapId],
);
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" };
}
},
);
// Cancel a swap (return employee to original department)
router.put(
"/:id/cancel",
authenticateToken,
authorize("SuperAdmin"),
async (ctx) => {
try {
const swapId = ctx.params.id;
// Get the swap record
const swaps = await db.query<EmployeeSwap[]>(
"SELECT * FROM employee_swaps WHERE id = ? AND status = 'Active'",
[swapId],
);
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" };
}
},
);
export default router;

View File

@@ -0,0 +1,218 @@
import { Router } from "@oak/oak";
import { db } from "../config/database.ts";
import {
authenticateToken,
authorize,
getCurrentUser,
} from "../middleware/auth.ts";
import type { JWTPayload, WorkAllocation } from "../types/index.ts";
const router = new Router();
// Get completed work allocations for reporting (with optional filters)
router.get(
"/completed-allocations",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
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");
const contractorId: string | null = params.get("contractorId");
const employeeId: string | null = params.get("employeeId");
let query = `
SELECT wa.*,
e.name as employee_name, e.username as employee_username,
e.phone_number as employee_phone,
s.name as supervisor_name,
c.name as contractor_name,
sd.name as sub_department_name,
d.name as department_name,
d.id as department_id,
sr.rate as standard_rate
FROM work_allocations wa
JOIN users e ON wa.employee_id = e.id
JOIN users s ON wa.supervisor_id = s.id
JOIN users c ON wa.contractor_id = c.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 standard_rates sr ON wa.sub_department_id = sr.sub_department_id
AND wa.activity = sr.activity
WHERE wa.status = 'Completed'
`;
const queryParams: unknown[] = [];
// Role-based filtering - Supervisors can only see their department
if (currentUser.role === "Supervisor") {
query += " AND e.department_id = ?";
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) {
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
router.get(
"/summary",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
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");
let departmentFilter = "";
const queryParams: unknown[] = [];
if (currentUser.role === "Supervisor") {
departmentFilter = " AND e.department_id = ?";
queryParams.push(currentUser.departmentId);
}
let dateFilter = "";
if (startDate) {
dateFilter += " AND wa.completion_date >= ?";
queryParams.push(startDate);
}
if (endDate) {
dateFilter += " AND wa.completion_date <= ?";
queryParams.push(endDate);
}
// Get summary by contractor
const byContractor = await db.query<any[]>(
`
SELECT
c.id as contractor_id,
c.name as contractor_name,
COUNT(*) as total_allocations,
SUM(COALESCE(wa.total_amount, wa.rate, 0)) as total_amount,
SUM(COALESCE(wa.units, 0)) as total_units
FROM work_allocations wa
JOIN users e ON wa.employee_id = e.id
JOIN users c ON wa.contractor_id = c.id
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
GROUP BY c.id, c.name
ORDER BY total_amount DESC
`,
queryParams,
);
// Get summary by sub-department
const bySubDepartment = await db.query<any[]>(
`
SELECT
sd.id as sub_department_id,
sd.name as sub_department_name,
d.name as department_name,
COUNT(*) as total_allocations,
SUM(COALESCE(wa.total_amount, wa.rate, 0)) as total_amount,
SUM(COALESCE(wa.units, 0)) as total_units
FROM work_allocations wa
JOIN users e ON wa.employee_id = e.id
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.id
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
GROUP BY sd.id, sd.name, d.name
ORDER BY total_amount DESC
`,
queryParams,
);
// Get summary by activity type
const byActivity = await db.query<any[]>(
`
SELECT
COALESCE(wa.activity, 'Standard') as activity,
COUNT(*) as total_allocations,
SUM(COALESCE(wa.total_amount, wa.rate, 0)) as total_amount,
SUM(COALESCE(wa.units, 0)) as total_units
FROM work_allocations wa
JOIN users e ON wa.employee_id = e.id
WHERE wa.status = 'Completed' ${departmentFilter} ${dateFilter}
GROUP BY wa.activity
ORDER BY total_amount DESC
`,
queryParams,
);
ctx.response.body = {
byContractor,
bySubDepartment,
byActivity,
};
} catch (error) {
console.error("Get report summary error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
export default router;

View File

@@ -0,0 +1,559 @@
import { Router } from "@oak/oak";
import { db } from "../config/database.ts";
import {
authenticateToken,
authorize,
getCurrentUser,
} from "../middleware/auth.ts";
import { sanitizeInput } from "../middleware/security.ts";
import type { Context } from "@oak/oak";
const router = new Router();
// Standard Rate interface
interface StandardRate {
id: number;
sub_department_id: number | null;
activity: string | null;
rate: number;
effective_date: Date;
created_by: number;
created_at: Date;
sub_department_name?: string;
department_name?: string;
department_id?: number;
created_by_name?: string;
}
// Get all standard rates (default rates for comparison)
router.get("/", authenticateToken, async (ctx: Context) => {
try {
const currentUser = getCurrentUser(ctx);
const params = ctx.request.url.searchParams;
const departmentId: string | number | null = params.get("departmentId");
const subDepartmentId: string | number | null = params.get(
"subDepartmentId",
);
const activity: string | null = params.get("activity");
let query = `
SELECT sr.*,
sd.name as sub_department_name,
d.name as department_name,
d.id as department_id,
u.name as created_by_name,
a.unit_of_measurement
FROM standard_rates sr
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.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
WHERE 1=1
`;
const queryParams: unknown[] = [];
// Supervisors can only see rates for their department
if (currentUser.role === "Supervisor") {
query += " AND d.id = ?";
queryParams.push(currentUser.departmentId);
}
if (departmentId) {
query += " AND d.id = ?";
queryParams.push(departmentId);
}
if (subDepartmentId) {
query += " AND sr.sub_department_id = ?";
queryParams.push(subDepartmentId);
}
if (activity) {
query += " AND sr.activity = ?";
queryParams.push(activity);
}
query += " ORDER BY sr.effective_date DESC, sr.created_at DESC";
const rates = await db.query<StandardRate[]>(query, queryParams);
ctx.response.body = rates;
} catch (error) {
console.error("Get standard rates error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Get all rates (contractor + standard) for SuperAdmin - all departments, sorted by date
router.get(
"/all-rates",
authenticateToken,
authorize("SuperAdmin"),
async (ctx: Context) => {
try {
const params = ctx.request.url.searchParams;
const departmentId: string | number | null = params.get("departmentId");
const startDate: string | null = params.get("startDate");
const endDate: string | null = params.get("endDate");
// Get contractor rates
let contractorQuery = `
SELECT
cr.id,
'contractor' as rate_type,
cr.contractor_id,
u.name as contractor_name,
cr.sub_department_id,
sd.name as sub_department_name,
d.id as department_id,
d.name as department_name,
cr.activity,
cr.rate,
cr.effective_date,
cr.created_at,
NULL as created_by,
NULL as created_by_name
FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.id
WHERE 1=1
`;
const contractorParams: unknown[] = [];
if (departmentId) {
contractorQuery += " AND d.id = ?";
contractorParams.push(departmentId);
}
if (startDate) {
contractorQuery += " AND cr.effective_date >= ?";
contractorParams.push(startDate);
}
if (endDate) {
contractorQuery += " AND cr.effective_date <= ?";
contractorParams.push(endDate);
}
// Get standard rates
let standardQuery = `
SELECT
sr.id,
'standard' as rate_type,
NULL as contractor_id,
NULL as contractor_name,
sr.sub_department_id,
sd.name as sub_department_name,
d.id as department_id,
d.name as department_name,
sr.activity,
sr.rate,
sr.effective_date,
sr.created_at,
sr.created_by,
u.name as created_by_name
FROM standard_rates sr
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.id
LEFT JOIN users u ON sr.created_by = u.id
WHERE 1=1
`;
const standardParams: unknown[] = [];
if (departmentId) {
standardQuery += " AND d.id = ?";
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) {
console.error("Get all rates error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Compare contractor rates with standard rates
router.get(
"/compare",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const params = ctx.request.url.searchParams;
const contractorId = params.get("contractorId");
const subDepartmentId = params.get("subDepartmentId");
let departmentFilter = "";
const queryParams: unknown[] = [];
if (currentUser.role === "Supervisor") {
departmentFilter = " AND d.id = ?";
queryParams.push(currentUser.departmentId);
}
// Get standard rates
let standardQuery = `
SELECT sr.*,
sd.name as sub_department_name,
d.name as department_name,
d.id as department_id,
a.unit_of_measurement
FROM standard_rates sr
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.id
LEFT JOIN activities a ON a.sub_department_id = sr.sub_department_id AND a.name = sr.activity
WHERE 1=1 ${departmentFilter}
`;
if (subDepartmentId) {
standardQuery += " AND sr.sub_department_id = ?";
queryParams.push(subDepartmentId);
}
standardQuery += " ORDER BY sr.effective_date DESC";
const standardRates = await db.query<StandardRate[]>(
standardQuery,
queryParams,
);
// Get contractor rates for comparison
let contractorQuery = `
SELECT cr.*,
u.name as contractor_name,
sd.name as sub_department_name,
d.name as department_name,
d.id as department_id,
a.unit_of_measurement
FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.id
LEFT JOIN activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
WHERE 1=1
`;
const contractorParams: unknown[] = [];
if (currentUser.role === "Supervisor") {
contractorQuery += " AND d.id = ?";
contractorParams.push(currentUser.departmentId);
}
if (contractorId) {
contractorQuery += " AND cr.contractor_id = ?";
contractorParams.push(contractorId);
}
if (subDepartmentId) {
contractorQuery += " AND cr.sub_department_id = ?";
contractorParams.push(subDepartmentId);
}
contractorQuery += " ORDER BY cr.effective_date DESC";
const contractorRates = await db.query<any[]>(
contractorQuery,
contractorParams,
);
// Build comparison data
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;
const contractorRate = cr.rate || 0;
const difference = contractorRate - standardRate;
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.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Create standard rate (Supervisor or SuperAdmin)
router.post(
"/",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as {
subDepartmentId?: number;
activity?: string;
rate: number;
effectiveDate: string;
};
const { subDepartmentId, activity, rate, effectiveDate } = body;
if (!rate || !effectiveDate) {
ctx.response.status = 400;
ctx.response.body = {
error: "Missing required fields (rate, effectiveDate)",
};
return;
}
// Verify sub-department belongs to supervisor's department if supervisor
if (subDepartmentId && currentUser.role === "Supervisor") {
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;
}
}
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,
d.name as department_name,
u.name as created_by_name,
a.unit_of_measurement
FROM standard_rates sr
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.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
WHERE sr.id = ?`,
[result.insertId],
);
ctx.response.status = 201;
ctx.response.body = newRate[0];
} catch (error) {
console.error("Create standard rate error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Update standard rate
router.put(
"/:id",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const rateId = ctx.params.id;
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
FROM standard_rates sr
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.id
WHERE sr.id = ?
`;
const params: unknown[] = [rateId];
const existing = await db.query<any[]>(query, params);
if (existing.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Standard rate not found" };
return;
}
// Supervisors can only update 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;
}
const updates: string[] = [];
const updateParams: unknown[] = [];
if (rate !== undefined) {
updates.push("rate = ?");
updateParams.push(rate);
}
if (activity !== undefined) {
updates.push("activity = ?");
updateParams.push(sanitizeInput(activity));
}
if (effectiveDate !== undefined) {
updates.push("effective_date = ?");
updateParams.push(effectiveDate);
}
if (updates.length === 0) {
ctx.response.status = 400;
ctx.response.body = { error: "No fields to update" };
return;
}
updateParams.push(rateId);
await db.execute(
`UPDATE standard_rates SET ${updates.join(", ")} WHERE id = ?`,
updateParams,
);
const updatedRate = await db.query<StandardRate[]>(
`SELECT sr.*,
sd.name as sub_department_name,
d.name as department_name,
u.name as created_by_name,
a.unit_of_measurement
FROM standard_rates sr
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.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
WHERE sr.id = ?`,
[rateId],
);
ctx.response.body = updatedRate[0];
} catch (error) {
console.error("Update standard rate error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Delete standard rate
router.delete(
"/:id",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx: Context) => {
try {
const currentUser = getCurrentUser(ctx);
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
LEFT JOIN sub_departments sd ON sr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.id
WHERE sr.id = ?`,
[rateId],
);
if (existing.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Standard rate not found" };
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" };
}
},
);
export default router;

View File

@@ -1,24 +1,41 @@
import { Router } from "@oak/oak"; import { type Context, Router } from "@oak/oak";
import { hash } 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";
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
import { sanitizeInput, isValidEmail } from "../middleware/security.ts"; // Helper function to hash password with proper salt generation
import type { User, CreateUserRequest, UpdateUserRequest } from "../types/index.ts"; async function hashPassword(password: string): Promise<string> {
const salt = await genSalt(config.BCRYPT_ROUNDS);
return await hash(password, salt);
}
import {
authenticateToken,
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,
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, d.name as department_name,
c.name as contractor_name c.name as contractor_name
FROM users u FROM users u
@@ -27,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) {
@@ -56,36 +73,42 @@ 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,
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, d.name as department_name,
c.name as contractor_name c.name as contractor_name
FROM users u FROM users u
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);
@@ -95,218 +118,334 @@ 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 { username, name, email, password, role, departmentId, contractorId } = body; async (ctx: Context) => {
try {
// Input validation const currentUser = getCurrentUser(ctx);
if (!username || !name || !email || !password || !role) { const body = await ctx.request.body.json() as CreateUserRequest;
ctx.response.status = 400; const {
ctx.response.body = { error: "Missing required fields" }; username,
return; name,
} email,
password,
// Sanitize inputs role,
const sanitizedUsername = sanitizeInput(username); departmentId,
const sanitizedName = sanitizeInput(name); contractorId,
const sanitizedEmail = sanitizeInput(email); phoneNumber,
aadharNumber,
// Validate email bankAccountNumber,
if (!isValidEmail(sanitizedEmail)) { bankName,
ctx.response.status = 400; bankIfsc,
ctx.response.body = { error: "Invalid email format" }; contractorAgreementNumber,
return; pfNumber,
} esicNumber,
} = body;
// 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 hash(password, config.BCRYPT_ROUNDS);
const result = await db.execute(
"INSERT INTO users (username, name, email, password, role, department_id, contractor_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
[sanitizedUsername, sanitizedName, sanitizedEmail, hashedPassword, role, departmentId || null, contractorId || 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,
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 } = 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); }
}
// Hash password
if (updates.length === 0) { const hashedPassword = await hashPassword(password);
ctx.response.status = 400;
ctx.response.body = { error: "No fields to update" }; const result = await db.execute(
return; `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)
params.push(userId); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
await db.execute( sanitizedUsername,
`UPDATE users SET ${updates.join(", ")} WHERE id = ?`, sanitizedName,
params sanitizedEmail,
); hashedPassword,
role,
const updatedUser = await db.query<User[]>( departmentId || null,
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id, 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.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, d.name as department_name,
c.name as contractor_name c.name as contractor_name
FROM users u FROM users u
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

@@ -14,6 +14,16 @@ export interface User {
created_at: Date; created_at: Date;
department_name?: string; department_name?: string;
contractor_name?: string; contractor_name?: string;
// Common fields for Employee and Contractor
phone_number?: string | null;
aadhar_number?: string | null;
bank_account_number?: string | null;
bank_name?: string | null;
bank_ifsc?: string | null;
// Contractor-specific fields
contractor_agreement_number?: string | null;
pf_number?: string | null;
esic_number?: string | null;
} }
export interface JWTPayload { export interface JWTPayload {
@@ -41,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;
@@ -66,7 +80,12 @@ export interface WorkAllocation {
} }
// Attendance types // Attendance types
export type AttendanceStatus = "CheckedIn" | "CheckedOut" | "Absent"; export type AttendanceStatus =
| "CheckedIn"
| "CheckedOut"
| "Absent"
| "HalfDay"
| "Late";
export interface Attendance { export interface Attendance {
id: number; id: number;
@@ -76,6 +95,7 @@ export interface Attendance {
check_out_time: Date | null; check_out_time: Date | null;
work_date: Date; work_date: Date;
status: AttendanceStatus; status: AttendanceStatus;
remark?: string | null;
created_at: Date; created_at: Date;
employee_name?: string; employee_name?: string;
supervisor_name?: string; supervisor_name?: string;
@@ -83,6 +103,49 @@ export interface Attendance {
contractor_name?: string; contractor_name?: string;
} }
// Employee swap types
export type SwapReason = "LeftWork" | "Sick" | "FinishedEarly" | "Other";
export type SwapStatus = "Active" | "Completed" | "Cancelled";
export interface EmployeeSwap {
id: number;
employee_id: number;
original_department_id: number;
target_department_id: number;
original_contractor_id: number | null;
target_contractor_id: number | null;
swap_reason: SwapReason;
reason_details: string | null;
work_completion_percentage: number;
swap_date: Date;
swapped_by: number;
status: SwapStatus;
created_at: Date;
completed_at: Date | null;
// Joined fields
employee_name?: string;
original_department_name?: string;
target_department_name?: string;
original_contractor_name?: string;
target_contractor_name?: string;
swapped_by_name?: string;
}
export interface CreateSwapRequest {
employeeId: number;
targetDepartmentId: number;
targetContractorId?: number;
swapReason: SwapReason;
reasonDetails?: string;
workCompletionPercentage?: number;
swapDate: string;
}
export interface UpdateAttendanceStatusRequest {
status: AttendanceStatus;
remark?: string;
}
// Contractor rate types // Contractor rate types
export interface ContractorRate { export interface ContractorRate {
id: number; id: number;
@@ -127,6 +190,16 @@ export interface CreateUserRequest {
role: UserRole; role: UserRole;
departmentId?: number | null; departmentId?: number | null;
contractorId?: number | null; contractorId?: number | null;
// Common fields for Employee and Contractor
phoneNumber?: string | null;
aadharNumber?: string | null;
bankAccountNumber?: string | null;
bankName?: string | null;
bankIfsc?: string | null;
// Contractor-specific fields
contractorAgreementNumber?: string | null;
pfNumber?: string | null;
esicNumber?: string | null;
} }
export interface UpdateUserRequest { export interface UpdateUserRequest {
@@ -136,6 +209,16 @@ export interface UpdateUserRequest {
departmentId?: number | null; departmentId?: number | null;
contractorId?: number | null; contractorId?: number | null;
isActive?: boolean; isActive?: boolean;
// Common fields for Employee and Contractor
phoneNumber?: string | null;
aadharNumber?: string | null;
bankAccountNumber?: string | null;
bankName?: string | null;
bankIfsc?: string | null;
// Contractor-specific fields
contractorAgreementNumber?: string | null;
pfNumber?: string | null;
esicNumber?: string | null;
} }
export interface ChangePasswordRequest { export interface ChangePasswordRequest {
@@ -168,3 +251,25 @@ export interface CreateContractorRateRequest {
rate: number; rate: number;
effectiveDate: string; effectiveDate: string;
} }
// Standard rate types
export interface StandardRate {
id: number;
sub_department_id: number | null;
activity: string | null;
rate: number;
effective_date: Date;
created_by: number;
created_at: Date;
sub_department_name?: string;
department_name?: string;
department_id?: number;
created_by_name?: string;
}
export interface CreateStandardRateRequest {
subDepartmentId?: number | null;
activity?: string | null;
rate: number;
effectiveDate: string;
}

View File

@@ -1,10 +0,0 @@
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=admin123
DB_NAME=work_allocation
DB_PORT=3306
JWT_SECRET=work_alloc_jwt_secret_key_change_in_production_2024
JWT_EXPIRES_IN=7d
PORT=3000

View File

@@ -1,10 +0,0 @@
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=work_allocation
DB_PORT=3306
JWT_SECRET=your_jwt_secret_key_change_this_in_production
JWT_EXPIRES_IN=7d
PORT=3000

View File

@@ -1,166 +0,0 @@
# Work Allocation Backend API
Simple Node.js/Express backend with MySQL database for the Work Allocation System.
## Setup
### 1. Install Dependencies
```bash
cd backend
npm install
```
### 2. Setup MySQL Database
1. Install MySQL if not already installed
2. Create the database and tables:
```bash
mysql -u root -p < database/schema.sql
```
Or manually:
- Login to MySQL: `mysql -u root -p`
- Run the SQL commands from `database/schema.sql`
### 3. Configure Environment
Copy `.env.example` to `.env` and update with your database credentials:
```bash
cp .env.example .env
```
Edit `.env`:
```env
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your_mysql_password
DB_NAME=work_allocation
DB_PORT=3306
JWT_SECRET=your_secret_key_here
JWT_EXPIRES_IN=7d
PORT=3000
```
### 4. Start Server
Development mode (with auto-reload):
```bash
npm run dev
```
Production mode:
```bash
npm start
```
The server will run on `http://localhost:3000`
## Default Credentials
**Super Admin:**
- Username: `admin`
- Password: `admin123`
**Note:** Change the default password immediately after first login!
## API Endpoints
### Authentication
- `POST /api/auth/login` - Login
- `GET /api/auth/me` - Get current user
- `POST /api/auth/change-password` - Change password
### Users
- `GET /api/users` - Get all users (with filters)
- `GET /api/users/:id` - Get user by ID
- `POST /api/users` - Create user
- `PUT /api/users/:id` - Update user
- `DELETE /api/users/:id` - Delete user
### Departments
- `GET /api/departments` - Get all departments
- `GET /api/departments/:id` - Get department by ID
- `GET /api/departments/:id/sub-departments` - Get sub-departments
- `POST /api/departments` - Create department (SuperAdmin only)
- `POST /api/departments/:id/sub-departments` - Create sub-department (SuperAdmin only)
### Work Allocations
- `GET /api/work-allocations` - Get all work allocations
- `GET /api/work-allocations/:id` - Get work allocation by ID
- `POST /api/work-allocations` - Create work allocation (Supervisor only)
- `PUT /api/work-allocations/:id/status` - Update status (Supervisor only)
- `DELETE /api/work-allocations/:id` - Delete work allocation (Supervisor only)
### Attendance
- `GET /api/attendance` - Get all attendance records
- `GET /api/attendance/:id` - Get attendance by ID
- `POST /api/attendance/check-in` - Check in employee (Supervisor only)
- `POST /api/attendance/check-out` - Check out employee (Supervisor only)
- `GET /api/attendance/summary/stats` - Get attendance summary
### Contractor Rates
- `GET /api/contractor-rates` - Get contractor rates
- `GET /api/contractor-rates/contractor/:contractorId/current` - Get current rate
- `POST /api/contractor-rates` - Set contractor rate (Supervisor/SuperAdmin only)
## Roles & Permissions
### SuperAdmin
- Full access to all features
- Can create/manage all users and departments
- Can view all data across departments
### Supervisor
- Can manage users (employees, contractors) in their department
- Can create work allocations for their department
- Can check in/out employees
- Can set contractor rates
- Can mark work as completed
### Contractor
- Can view work allocations assigned to them
- Can view employees under them
### Employee
- Can view their own work allocations
- Can view their attendance records
- Can see contractor rates
## Database Schema
### Tables
- `departments` - Main departments (Tudki, Dana, Groundnut)
- `sub_departments` - Sub-departments (17 for Groundnut)
- `users` - All users (SuperAdmin, Supervisor, Contractor, Employee)
- `contractor_rates` - Contractor rate history
- `work_allocations` - Work assignments
- `attendance` - Check-in/out records
## Development Notes
- The server uses ES modules (type: "module" in package.json)
- JWT tokens are used for authentication
- Passwords are hashed using bcryptjs
- All timestamps are in UTC
- The API uses role-based access control (RBAC)

View File

@@ -1,33 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load .env from backend directory
dotenv.config({ path: join(__dirname, '..', '.env') });
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'admin123',
database: process.env.DB_NAME || 'work_allocation',
port: process.env.DB_PORT || 3306,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Test connection
pool.getConnection()
.then(connection => {
console.log('✅ Database connected successfully');
connection.release();
})
.catch(err => {
console.error('❌ Database connection failed:', err.message);
});
export default pool;

View File

@@ -1,240 +0,0 @@
import bcrypt from 'bcryptjs';
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
async function seedDatabase() {
let connection;
try {
// Connect to database with retry logic
console.log('🔌 Connecting to database...');
let retries = 5;
while (retries > 0) {
try {
connection = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'admin123',
database: process.env.DB_NAME || 'work_allocation',
port: process.env.DB_PORT || 3306,
connectTimeout: 10000,
enableKeepAlive: true,
keepAliveInitialDelay: 0
});
break;
} catch (err) {
retries--;
if (retries === 0) throw err;
console.log(` ⏳ Retrying connection... (${5 - retries}/5)`);
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
console.log('✅ Connected to database');
console.log('');
// 1. Seed Departments
console.log('📁 Seeding departments...');
const [existingDepts] = await connection.query('SELECT COUNT(*) as count FROM departments');
if (existingDepts[0].count === 0) {
await connection.query(`
INSERT INTO departments (name) VALUES
('Tudki'),
('Dana'),
('Groundnut')
`);
console.log(' ✅ Departments created');
} else {
console.log(' Departments already exist');
}
// 2. Seed Sub-departments for Groundnut
console.log('📂 Seeding sub-departments...');
const [groundnutDept] = await connection.query('SELECT id FROM departments WHERE name = ?', ['Groundnut']);
let groundnutId = null;
if (groundnutDept.length > 0) {
groundnutId = groundnutDept[0].id;
const [existingSubDepts] = await connection.query('SELECT COUNT(*) as count FROM sub_departments WHERE department_id = ?', [groundnutId]);
if (existingSubDepts[0].count === 0) {
await connection.query(`
INSERT INTO sub_departments (department_id, name, primary_activity) VALUES
(?, 'Mufali Aavak Katai', 'Loading/Unloading'),
(?, 'Mufali Aavak Dhang', 'Loading/Unloading'),
(?, 'Dhang Se Katai', 'Loading/Unloading'),
(?, 'Guthli Bori Silai Dhang', 'Loading/Unloading'),
(?, 'Guthali dada Pala Tulai Silai Dhang', 'Loading/Unloading'),
(?, 'Mufali Patthar Bori silai dhang', 'Loading/Unloading'),
(?, 'Mufali Patthar Bori Utrai', 'Loading/Unloading'),
(?, 'Bardana Bandal Loading Unloading', 'Loading/Unloading'),
(?, 'Bardana Gatthi Loading', 'Loading/Unloading'),
(?, 'Black Dana Loading/Unloading', 'Loading/Unloading'),
(?, 'Pre Cleaning', 'Pre Cleaning'),
(?, 'Destoner', 'Destoner'),
(?, 'Water', 'Water'),
(?, 'Decordicater', 'Decordicater & Cleaning'),
(?, 'Round Chalna', 'Round Chalna & Cleaning'),
(?, 'Cleaning', 'Decordicater & Cleaning'),
(?, 'Round Chalna No.1', 'Round Chalna No.1')
`, Array(17).fill(groundnutId));
console.log(' ✅ Sub-departments created');
} else {
console.log(' Sub-departments already exist');
}
}
// 3. Seed SuperAdmin
console.log('👤 Seeding SuperAdmin user...');
const [existingAdmin] = await connection.query('SELECT id FROM users WHERE username = ?', ['admin']);
const adminPassword = await bcrypt.hash('admin123', 10);
if (existingAdmin.length > 0) {
await connection.query(
'UPDATE users SET password = ?, is_active = TRUE WHERE username = ?',
[adminPassword, 'admin']
);
console.log(' ✅ SuperAdmin password updated');
} else {
await connection.query(
'INSERT INTO users (username, name, email, password, role, is_active) VALUES (?, ?, ?, ?, ?, ?)',
['admin', 'Super Admin', 'admin@workallocate.com', adminPassword, 'SuperAdmin', true]
);
console.log(' ✅ SuperAdmin created');
}
// 4. Seed Sample Supervisors
console.log('👥 Seeding sample supervisors...');
const [tudkiDept] = await connection.query('SELECT id FROM departments WHERE name = ?', ['Tudki']);
const [danaDept] = await connection.query('SELECT id FROM departments WHERE name = ?', ['Dana']);
const supervisorPassword = await bcrypt.hash('supervisor123', 10);
const supervisors = [
{ username: 'supervisor_tudki', name: 'Tudki Supervisor', email: 'supervisor.tudki@workallocate.com', deptId: tudkiDept[0]?.id },
{ username: 'supervisor_dana', name: 'Dana Supervisor', email: 'supervisor.dana@workallocate.com', deptId: danaDept[0]?.id },
{ username: 'supervisor_groundnut', name: 'Groundnut Supervisor', email: 'supervisor.groundnut@workallocate.com', deptId: groundnutId }
];
for (const sup of supervisors) {
if (sup.deptId) {
const [existing] = await connection.query('SELECT id FROM users WHERE username = ?', [sup.username]);
if (existing.length === 0) {
await connection.query(
'INSERT INTO users (username, name, email, password, role, department_id, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)',
[sup.username, sup.name, sup.email, supervisorPassword, 'Supervisor', sup.deptId, true]
);
console.log(`${sup.name} created`);
} else {
console.log(` ${sup.name} already exists`);
}
}
}
// 5. Seed Sample Contractors
console.log('🏗️ Seeding sample contractors...');
const contractorPassword = await bcrypt.hash('contractor123', 10);
const contractors = [
{ username: 'contractor1', name: 'Contractor One', email: 'contractor1@workallocate.com', deptId: groundnutId },
{ username: 'contractor2', name: 'Contractor Two', email: 'contractor2@workallocate.com', deptId: groundnutId }
];
for (const con of contractors) {
const [existing] = await connection.query('SELECT id FROM users WHERE username = ?', [con.username]);
if (existing.length === 0) {
await connection.query(
'INSERT INTO users (username, name, email, password, role, department_id, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)',
[con.username, con.name, con.email, contractorPassword, 'Contractor', con.deptId, true]
);
console.log(`${con.name} created`);
} else {
console.log(` ${con.name} already exists`);
}
}
// 6. Seed Sample Employees
console.log('👷 Seeding sample employees...');
const [contractor1] = await connection.query('SELECT id FROM users WHERE username = ?', ['contractor1']);
const employeePassword = await bcrypt.hash('employee123', 10);
if (contractor1.length > 0) {
const employees = [
{ username: 'employee1', name: 'Employee One', email: 'employee1@workallocate.com' },
{ username: 'employee2', name: 'Employee Two', email: 'employee2@workallocate.com' },
{ username: 'employee3', name: 'Employee Three', email: 'employee3@workallocate.com' }
];
for (const emp of employees) {
const [existing] = await connection.query('SELECT id FROM users WHERE username = ?', [emp.username]);
if (existing.length === 0) {
await connection.query(
'INSERT INTO users (username, name, email, password, role, department_id, contractor_id, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[emp.username, emp.name, emp.email, employeePassword, 'Employee', groundnutId, contractor1[0].id, true]
);
console.log(`${emp.name} created`);
} else {
console.log(` ${emp.name} already exists`);
}
}
}
// 7. Seed Contractor Rates
console.log('💰 Seeding contractor rates...');
if (contractor1.length > 0) {
const [existingRate] = await connection.query(
'SELECT id FROM contractor_rates WHERE contractor_id = ?',
[contractor1[0].id]
);
if (existingRate.length === 0) {
await connection.query(
'INSERT INTO contractor_rates (contractor_id, rate, effective_date) VALUES (?, ?, CURDATE())',
[contractor1[0].id, 500.00]
);
console.log(' ✅ Contractor rates created');
} else {
console.log(' Contractor rates already exist');
}
}
console.log('');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ Database seeding completed successfully!');
console.log('');
console.log('🔑 Default Login Credentials:');
console.log('');
console.log(' SuperAdmin:');
console.log(' Username: admin');
console.log(' Password: admin123');
console.log('');
console.log(' Supervisor (Groundnut):');
console.log(' Username: supervisor_groundnut');
console.log(' Password: supervisor123');
console.log('');
console.log(' Contractor:');
console.log(' Username: contractor1');
console.log(' Password: contractor123');
console.log('');
console.log(' Employee:');
console.log(' Username: employee1');
console.log(' Password: employee123');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('');
} catch (error) {
console.error('❌ Error seeding database:', error.message);
process.exit(1);
} finally {
if (connection) {
await connection.end();
}
}
}
seedDatabase();

View File

@@ -1,135 +1,164 @@
-- Work Allocation System Database Schema -- Work Allocation System Database Schema
-- This version is for Docker init (database already created by environment variables) -- MySQL 8.0
-- Departments table -- Create departments table
CREATE TABLE IF NOT EXISTS departments ( CREATE TABLE IF NOT EXISTS departments (
id INT PRIMARY KEY AUTO_INCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
); );
-- Sub-departments table (for Groundnut department) -- Create sub_departments table
CREATE TABLE IF NOT EXISTS sub_departments ( CREATE TABLE IF NOT EXISTS sub_departments (
id INT PRIMARY KEY AUTO_INCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL, department_id INT NOT NULL,
name VARCHAR(200) NOT NULL, name VARCHAR(100) NOT NULL,
primary_activity VARCHAR(200) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE CASCADE,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE CASCADE UNIQUE KEY unique_subdept (department_id, name)
); );
-- Users table (for all roles: SuperAdmin, Supervisor, Contractor, Employee) -- Create activities table (activities belong to sub-departments)
CREATE TABLE IF NOT EXISTS activities (
id INT AUTO_INCREMENT PRIMARY KEY,
sub_department_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
unit_of_measurement ENUM('Per Bag', 'Fixed Rate-Per Person') NOT NULL DEFAULT 'Per Bag',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE CASCADE,
UNIQUE KEY unique_activity (sub_department_id, name)
);
-- Create users table
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL, name VARCHAR(100) NOT NULL,
email VARCHAR(200) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
role ENUM('SuperAdmin', 'Supervisor', 'Contractor', 'Employee') NOT NULL, role ENUM('SuperAdmin', 'Supervisor', 'Contractor', 'Employee') NOT NULL,
department_id INT, department_id INT,
contractor_id INT, contractor_id INT,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
-- Common fields for Employee and Contractor
phone_number VARCHAR(20),
aadhar_number VARCHAR(12),
bank_account_number VARCHAR(30),
bank_name VARCHAR(100),
bank_ifsc VARCHAR(20),
-- Contractor-specific fields
contractor_agreement_number VARCHAR(50),
pf_number VARCHAR(30),
esic_number VARCHAR(30),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL, FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL,
FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE SET NULL
); );
-- Contractor rates table (rates per contractor + sub-department combination) -- Create work_allocations table
CREATE TABLE IF NOT EXISTS contractor_rates (
id INT PRIMARY KEY AUTO_INCREMENT,
contractor_id INT NOT NULL,
sub_department_id INT,
activity VARCHAR(200),
rate DECIMAL(10, 2) NOT NULL,
effective_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE SET NULL
);
-- Work allocations table
CREATE TABLE IF NOT EXISTS work_allocations ( CREATE TABLE IF NOT EXISTS work_allocations (
id INT PRIMARY KEY AUTO_INCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL, employee_id INT NOT NULL,
supervisor_id INT NOT NULL, supervisor_id INT NOT NULL,
contractor_id INT NOT NULL, contractor_id INT NOT NULL,
sub_department_id INT, sub_department_id INT,
activity VARCHAR(100), activity VARCHAR(255),
description TEXT, description TEXT,
assigned_date DATE NOT NULL, assigned_date DATE NOT NULL,
status ENUM('Pending', 'InProgress', 'Completed', 'Cancelled') DEFAULT 'Pending',
completion_date DATE, completion_date DATE,
status ENUM('Pending', 'InProgress', 'Completed', 'Cancelled') DEFAULT 'Pending',
rate DECIMAL(10, 2), rate DECIMAL(10, 2),
units DECIMAL(10, 2), units DECIMAL(10, 2),
total_amount DECIMAL(10, 2), total_amount DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (supervisor_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (supervisor_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE SET NULL FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE SET NULL
); );
-- Attendance table -- Create attendance table
CREATE TABLE IF NOT EXISTS attendance ( CREATE TABLE IF NOT EXISTS attendance (
id INT PRIMARY KEY AUTO_INCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL, employee_id INT NOT NULL,
supervisor_id INT NOT NULL, supervisor_id INT NOT NULL,
check_in_time DATETIME NOT NULL, check_in_time DATETIME,
check_out_time DATETIME, check_out_time DATETIME,
work_date DATE NOT NULL, work_date DATE NOT NULL,
status ENUM('CheckedIn', 'CheckedOut') DEFAULT 'CheckedIn', status ENUM('CheckedIn', 'CheckedOut', 'Absent', 'HalfDay', 'Late') DEFAULT 'CheckedIn',
remark VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (supervisor_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (supervisor_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY unique_attendance (employee_id, work_date)
); );
-- Insert default departments -- Create employee_swaps table for tracking employee department transfers
INSERT IGNORE INTO departments (name) VALUES CREATE TABLE IF NOT EXISTS employee_swaps (
('Tudki'), id INT AUTO_INCREMENT PRIMARY KEY,
('Dana'), employee_id INT NOT NULL,
('Groundnut'); original_department_id INT NOT NULL,
target_department_id INT NOT NULL,
original_contractor_id INT,
target_contractor_id INT,
swap_reason ENUM('LeftWork', 'Sick', 'FinishedEarly', 'Other') NOT NULL,
reason_details VARCHAR(500),
work_completion_percentage INT DEFAULT 0,
swap_date DATE NOT NULL,
swapped_by INT NOT NULL,
status ENUM('Active', 'Completed', 'Cancelled') DEFAULT 'Active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (original_department_id) REFERENCES departments(id) ON DELETE CASCADE,
FOREIGN KEY (target_department_id) REFERENCES departments(id) ON DELETE CASCADE,
FOREIGN KEY (original_contractor_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (target_contractor_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (swapped_by) REFERENCES users(id) ON DELETE CASCADE
);
-- Insert Groundnut sub-departments -- Create contractor_rates table
INSERT IGNORE INTO sub_departments (department_id, name, primary_activity) CREATE TABLE IF NOT EXISTS contractor_rates (
SELECT id, 'Mufali Aavak Katai', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut' id INT AUTO_INCREMENT PRIMARY KEY,
UNION ALL contractor_id INT NOT NULL,
SELECT id, 'Mufali Aavak Dhang', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut' sub_department_id INT,
UNION ALL activity VARCHAR(255),
SELECT id, 'Dhang Se Katai', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut' rate DECIMAL(10, 2) NOT NULL,
UNION ALL effective_date DATE NOT NULL,
SELECT id, 'Guthli Bori Silai Dhang', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNION ALL FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE CASCADE,
SELECT id, 'Guthali dada Pala Tulai Silai Dhang', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut' FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE SET NULL
UNION ALL );
SELECT id, 'Mufali Patthar Bori silai dhang', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Mufali Patthar Bori Utrai', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Bardana Bandal Loading Unloading', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Bardana Gatthi Loading', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Black Dana Loading/Unloading', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Pre Cleaning', 'Pre Cleaning' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Destoner', 'Destoner' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Water', 'Water' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Decordicater', 'Decordicater & Cleaning' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Round Chalna', 'Round Chalna & Cleaning' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Cleaning', 'Decordicater & Cleaning' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Round Chalna No.1', 'Round Chalna No.1' FROM departments WHERE name = 'Groundnut';
-- Note: Admin user will be created by running: npm run seed -- Create standard_rates table (default rates for comparison with contractor rates)
-- This ensures the password is properly hashed with bcrypt CREATE TABLE IF NOT EXISTS standard_rates (
id INT AUTO_INCREMENT PRIMARY KEY,
sub_department_id INT,
activity VARCHAR(255),
rate DECIMAL(10, 2) NOT NULL,
effective_date DATE NOT NULL,
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
);
-- Create indexes for better query performance
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_department ON users(department_id);
CREATE INDEX idx_users_contractor ON users(contractor_id);
CREATE INDEX idx_users_phone ON users(phone_number);
CREATE INDEX idx_users_aadhar ON users(aadhar_number);
CREATE INDEX idx_work_allocations_employee ON work_allocations(employee_id);
CREATE INDEX idx_work_allocations_supervisor ON work_allocations(supervisor_id);
CREATE INDEX idx_work_allocations_contractor ON work_allocations(contractor_id);
CREATE INDEX idx_work_allocations_date ON work_allocations(assigned_date);
CREATE INDEX idx_work_allocations_status ON work_allocations(status);
CREATE INDEX idx_attendance_employee ON attendance(employee_id);
CREATE INDEX idx_attendance_date ON attendance(work_date);
CREATE INDEX idx_attendance_status ON attendance(status);
CREATE INDEX idx_contractor_rates_contractor ON contractor_rates(contractor_id);
CREATE INDEX idx_contractor_rates_date ON contractor_rates(effective_date);
CREATE INDEX idx_standard_rates_subdept ON standard_rates(sub_department_id);
CREATE INDEX idx_standard_rates_date ON standard_rates(effective_date);
CREATE INDEX idx_standard_rates_created_by ON standard_rates(created_by);

View File

@@ -1,11 +0,0 @@
-- Migration: Add activity, units, and total_amount columns to work_allocations table
-- Run this if you have an existing database without these columns
ALTER TABLE work_allocations
ADD COLUMN IF NOT EXISTS activity VARCHAR(100) AFTER sub_department_id;
ALTER TABLE work_allocations
ADD COLUMN IF NOT EXISTS units DECIMAL(10, 2) AFTER rate;
ALTER TABLE work_allocations
ADD COLUMN IF NOT EXISTS total_amount DECIMAL(10, 2) AFTER units;

View File

@@ -1,13 +0,0 @@
-- Migration: Add sub_department_id and activity columns to contractor_rates table
-- Run this if you have an existing database
-- Add sub_department_id column if it doesn't exist
ALTER TABLE contractor_rates
ADD COLUMN IF NOT EXISTS sub_department_id INT NULL,
ADD COLUMN IF NOT EXISTS activity VARCHAR(200) NULL;
-- Add foreign key constraint for sub_department_id
-- Note: This may fail if the constraint already exists
ALTER TABLE contractor_rates
ADD CONSTRAINT fk_contractor_rates_sub_department
FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,23 @@
-- Migration: Add standard_rates table
-- Run this migration to add the standard_rates table for supervisor-managed default rates
-- Create standard_rates table (default rates for comparison with contractor rates)
CREATE TABLE IF NOT EXISTS standard_rates (
id INT AUTO_INCREMENT PRIMARY KEY,
sub_department_id INT,
activity VARCHAR(255),
rate DECIMAL(10, 2) NOT NULL,
effective_date DATE NOT NULL,
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
);
-- Create indexes for standard_rates
CREATE INDEX idx_standard_rates_subdept ON standard_rates(sub_department_id);
CREATE INDEX idx_standard_rates_date ON standard_rates(effective_date);
CREATE INDEX idx_standard_rates_created_by ON standard_rates(created_by);
-- Verify table was created
SELECT 'standard_rates table created successfully' AS status;

View File

@@ -0,0 +1,18 @@
-- Migration: Add personal and bank details to users table
-- Run this migration on existing databases to add the new fields
-- Common fields for Employee and Contractor
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) AFTER is_active;
ALTER TABLE users ADD COLUMN aadhar_number VARCHAR(12) AFTER phone_number;
ALTER TABLE users ADD COLUMN bank_account_number VARCHAR(30) AFTER aadhar_number;
ALTER TABLE users ADD COLUMN bank_name VARCHAR(100) AFTER bank_account_number;
ALTER TABLE users ADD COLUMN bank_ifsc VARCHAR(20) AFTER bank_name;
-- Contractor-specific fields
ALTER TABLE users ADD COLUMN contractor_agreement_number VARCHAR(50) AFTER bank_ifsc;
ALTER TABLE users ADD COLUMN pf_number VARCHAR(30) AFTER contractor_agreement_number;
ALTER TABLE users ADD COLUMN esic_number VARCHAR(30) AFTER pf_number;
-- Add indexes for commonly queried fields
CREATE INDEX idx_users_phone ON users(phone_number);
CREATE INDEX idx_users_aadhar ON users(aadhar_number);

View File

@@ -1,134 +0,0 @@
-- Work Allocation System Database Schema
-- Create database
CREATE DATABASE IF NOT EXISTS work_allocation;
USE work_allocation;
-- Departments table
CREATE TABLE departments (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Sub-departments table (for Groundnut department)
CREATE TABLE sub_departments (
id INT PRIMARY KEY AUTO_INCREMENT,
department_id INT NOT NULL,
name VARCHAR(200) NOT NULL,
primary_activity VARCHAR(200) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE CASCADE
);
-- Users table (for all roles: SuperAdmin, Supervisor, Contractor, Employee)
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
email VARCHAR(200) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role ENUM('SuperAdmin', 'Supervisor', 'Contractor', 'Employee') NOT NULL,
department_id INT,
contractor_id INT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL,
FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE SET NULL
);
-- Contractor rates table
CREATE TABLE contractor_rates (
id INT PRIMARY KEY AUTO_INCREMENT,
contractor_id INT NOT NULL,
rate DECIMAL(10, 2) NOT NULL,
effective_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Work allocations table
CREATE TABLE work_allocations (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL,
supervisor_id INT NOT NULL,
contractor_id INT NOT NULL,
sub_department_id INT,
description TEXT,
assigned_date DATE NOT NULL,
status ENUM('Pending', 'InProgress', 'Completed', 'Cancelled') DEFAULT 'Pending',
completion_date DATE,
rate DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (supervisor_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (contractor_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (sub_department_id) REFERENCES sub_departments(id) ON DELETE SET NULL
);
-- Attendance table
CREATE TABLE attendance (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL,
supervisor_id INT NOT NULL,
check_in_time DATETIME NOT NULL,
check_out_time DATETIME,
work_date DATE NOT NULL,
status ENUM('CheckedIn', 'CheckedOut') DEFAULT 'CheckedIn',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (supervisor_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Insert default departments
INSERT INTO departments (name) VALUES
('Tudki'),
('Dana'),
('Groundnut');
-- Insert Groundnut sub-departments
INSERT INTO sub_departments (department_id, name, primary_activity)
SELECT id, 'Mufali Aavak Katai', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Mufali Aavak Dhang', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Dhang Se Katai', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Guthli Bori Silai Dhang', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Guthali dada Pala Tulai Silai Dhang', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Mufali Patthar Bori silai dhang', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Mufali Patthar Bori Utrai', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Bardana Bandal Loading Unloading', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Bardana Gatthi Loading', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Black Dana Loading/Unloading', 'Loading/Unloading' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Pre Cleaning', 'Pre Cleaning' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Destoner', 'Destoner' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Water', 'Water' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Decordicater', 'Decordicater & Cleaning' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Round Chalna', 'Round Chalna & Cleaning' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Cleaning', 'Decordicater & Cleaning' FROM departments WHERE name = 'Groundnut'
UNION ALL
SELECT id, 'Round Chalna No.1', 'Round Chalna No.1' FROM departments WHERE name = 'Groundnut';
-- Insert default SuperAdmin (password: admin123)
-- Password is hashed using bcrypt: $2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
INSERT INTO users (username, name, email, password, role) VALUES
('admin', 'Super Admin', 'admin@workallocate.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'SuperAdmin');

View File

@@ -1,72 +0,0 @@
import bcrypt from 'bcryptjs';
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
async function seedAdmin() {
let connection;
try {
// Connect to database (use root for seeding)
connection = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: 'root',
password: 'rootpassword',
database: process.env.DB_NAME || 'work_allocation',
port: process.env.DB_PORT || 3306
});
console.log('✅ Connected to database');
// Check if admin already exists
const [existingUsers] = await connection.query(
'SELECT id FROM users WHERE username = ?',
['admin']
);
if (existingUsers.length > 0) {
console.log(' Admin user already exists, updating password...');
// Generate new password hash
const passwordHash = await bcrypt.hash('admin123', 10);
// Update existing admin user
await connection.query(
'UPDATE users SET password = ? WHERE username = ?',
[passwordHash, 'admin']
);
console.log('✅ Admin password updated successfully');
} else {
console.log('📝 Creating admin user...');
// Generate password hash
const passwordHash = await bcrypt.hash('admin123', 10);
// Insert admin user
await connection.query(
'INSERT INTO users (username, name, email, password, role) VALUES (?, ?, ?, ?, ?)',
['admin', 'Super Admin', 'admin@workallocate.com', passwordHash, 'SuperAdmin']
);
console.log('✅ Admin user created successfully');
}
console.log('');
console.log('🔑 Default Login Credentials:');
console.log(' Username: admin');
console.log(' Password: admin123');
console.log('');
} catch (error) {
console.error('❌ Error seeding admin user:', error.message);
process.exit(1);
} finally {
if (connection) {
await connection.end();
}
}
}
seedAdmin();

View File

@@ -1,32 +0,0 @@
import jwt from 'jsonwebtoken';
export const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
};
export const authorize = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};

1120
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
{
"name": "work-allocation-backend",
"version": "1.0.0",
"description": "Simple backend for Work Allocation System",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"seed": "node database/database_seed.js"
},
"dependencies": {
"express": "^4.18.2",
"mysql2": "^3.6.5",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1"
}
}

View File

@@ -1,259 +0,0 @@
import express from 'express';
import db from '../config/database.js';
import { authenticateToken, authorize } from '../middleware/auth.js';
const router = express.Router();
// Get all attendance records
router.get('/', authenticateToken, async (req, res) => {
try {
const { employeeId, startDate, endDate, status } = req.query;
let query = `
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 1=1
`;
const params = [];
// Role-based filtering
if (req.user.role === 'Supervisor') {
query += ' AND a.supervisor_id = ?';
params.push(req.user.id);
} else if (req.user.role === 'Employee') {
query += ' AND a.employee_id = ?';
params.push(req.user.id);
}
if (employeeId) {
query += ' AND a.employee_id = ?';
params.push(employeeId);
}
if (startDate) {
query += ' AND a.work_date >= ?';
params.push(startDate);
}
if (endDate) {
query += ' AND a.work_date <= ?';
params.push(endDate);
}
if (status) {
query += ' AND a.status = ?';
params.push(status);
}
query += ' ORDER BY a.work_date DESC, a.check_in_time DESC';
const [records] = await db.query(query, params);
res.json(records);
} catch (error) {
console.error('Get attendance error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get attendance by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const [records] = await db.query(
`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 = ?`,
[req.params.id]
);
if (records.length === 0) {
return res.status(404).json({ error: 'Attendance record not found' });
}
res.json(records[0]);
} catch (error) {
console.error('Get attendance error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Check in employee (Supervisor or SuperAdmin)
router.post('/check-in', authenticateToken, authorize('Supervisor', 'SuperAdmin'), async (req, res) => {
try {
const { employeeId, workDate } = req.body;
if (!employeeId || !workDate) {
return res.status(400).json({ error: 'Employee ID and work date required' });
}
// Verify employee exists (SuperAdmin can check in any employee, Supervisor only their department)
let employeeQuery = 'SELECT * FROM users WHERE id = ? AND role = ?';
let employeeParams = [employeeId, 'Employee'];
if (req.user.role === 'Supervisor') {
employeeQuery += ' AND department_id = ?';
employeeParams.push(req.user.departmentId);
}
const [employees] = await db.query(employeeQuery, employeeParams);
if (employees.length === 0) {
return res.status(403).json({ error: 'Employee not found or not in your department' });
}
// Check if already checked in today
const [existing] = await db.query(
'SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?',
[employeeId, workDate, 'CheckedIn']
);
if (existing.length > 0) {
return res.status(400).json({ error: 'Employee already checked in today' });
}
const checkInTime = new Date();
const [result] = await db.query(
'INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)',
[employeeId, req.user.id, checkInTime, workDate, 'CheckedIn']
);
const [newRecord] = await db.query(
`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]
);
res.status(201).json(newRecord[0]);
} catch (error) {
console.error('Check in error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Check out employee (Supervisor or SuperAdmin)
router.post('/check-out', authenticateToken, authorize('Supervisor', 'SuperAdmin'), async (req, res) => {
try {
const { employeeId, workDate } = req.body;
if (!employeeId || !workDate) {
return res.status(400).json({ error: 'Employee ID and work date required' });
}
// Find the check-in record (SuperAdmin can check out any, Supervisor only their own)
let query = 'SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?';
let params = [employeeId, workDate, 'CheckedIn'];
if (req.user.role === 'Supervisor') {
query += ' AND supervisor_id = ?';
params.push(req.user.id);
}
const [records] = await db.query(query, params);
if (records.length === 0) {
return res.status(404).json({ error: 'No check-in record found for today' });
}
const checkOutTime = new Date();
await db.query(
'UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?',
[checkOutTime, 'CheckedOut', records[0].id]
);
const [updatedRecord] = await db.query(
`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]
);
res.json(updatedRecord[0]);
} catch (error) {
console.error('Check out error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get attendance summary
router.get('/summary/stats', authenticateToken, async (req, res) => {
try {
const { startDate, endDate, departmentId } = req.query;
let query = `
SELECT
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 = 'CheckedOut' THEN a.employee_id END) as checked_out,
d.name as department_name
FROM attendance a
JOIN users e ON a.employee_id = e.id
LEFT JOIN departments d ON e.department_id = d.id
WHERE 1=1
`;
const params = [];
if (req.user.role === 'Supervisor') {
query += ' AND a.supervisor_id = ?';
params.push(req.user.id);
}
if (startDate) {
query += ' AND a.work_date >= ?';
params.push(startDate);
}
if (endDate) {
query += ' AND a.work_date <= ?';
params.push(endDate);
}
if (departmentId) {
query += ' AND e.department_id = ?';
params.push(departmentId);
}
query += ' GROUP BY d.id, d.name';
const [summary] = await db.query(query, params);
res.json(summary);
} catch (error) {
console.error('Get attendance summary error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -1,114 +0,0 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import db from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
const router = express.Router();
// Login
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
const [users] = await db.query(
'SELECT * FROM users WHERE username = ? AND is_active = TRUE',
[username]
);
if (users.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = users[0];
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{
id: user.id,
username: user.username,
role: user.role,
departmentId: user.department_id
},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
res.json({
token,
user: {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
role: user.role,
department_id: user.department_id,
contractor_id: user.contractor_id,
is_active: user.is_active
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get current user
router.get('/me', authenticateToken, async (req, res) => {
try {
const [users] = await db.query(
'SELECT id, username, name, email, role, department_id, contractor_id FROM users WHERE id = ?',
[req.user.id]
);
if (users.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(users[0]);
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Change password
router.post('/change-password', authenticateToken, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Current and new password required' });
}
const [users] = await db.query('SELECT password FROM users WHERE id = ?', [req.user.id]);
if (users.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
const validPassword = await bcrypt.compare(currentPassword, users[0].password);
if (!validPassword) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
await db.query('UPDATE users SET password = ? WHERE id = ?', [hashedPassword, req.user.id]);
res.json({ message: 'Password changed successfully' });
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({ error: 'Internal server error' });p
}
});
export default router;

View File

@@ -1,198 +0,0 @@
import express from 'express';
import db from '../config/database.js';
import { authenticateToken, authorize } from '../middleware/auth.js';
const router = express.Router();
// Get contractor rates
router.get('/', authenticateToken, async (req, res) => {
try {
const { contractorId, subDepartmentId } = req.query;
let query = `
SELECT cr.*,
u.name as contractor_name, u.username as contractor_username,
sd.name as sub_department_name,
d.name as department_name
FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
LEFT JOIN departments d ON sd.department_id = d.id
WHERE 1=1
`;
const params = [];
if (contractorId) {
query += ' AND cr.contractor_id = ?';
params.push(contractorId);
}
if (subDepartmentId) {
query += ' AND cr.sub_department_id = ?';
params.push(subDepartmentId);
}
query += ' ORDER BY cr.effective_date DESC, cr.created_at DESC';
const [rates] = await db.query(query, params);
res.json(rates);
} catch (error) {
console.error('Get contractor rates error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get current rate for a contractor + sub-department combination
router.get('/contractor/:contractorId/current', authenticateToken, async (req, res) => {
try {
const { subDepartmentId } = req.query;
let query = `
SELECT cr.*,
u.name as contractor_name, u.username as contractor_username,
sd.name as sub_department_name
FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
WHERE cr.contractor_id = ?
`;
const params = [req.params.contractorId];
if (subDepartmentId) {
query += ' AND cr.sub_department_id = ?';
params.push(subDepartmentId);
}
query += ' ORDER BY cr.effective_date DESC LIMIT 1';
const [rates] = await db.query(query, params);
if (rates.length === 0) {
return res.status(404).json({ error: 'No rate found for contractor' });
}
res.json(rates[0]);
} catch (error) {
console.error('Get current rate error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Set contractor rate (Supervisor or SuperAdmin)
router.post('/', authenticateToken, authorize('Supervisor', 'SuperAdmin'), async (req, res) => {
try {
const { contractorId, subDepartmentId, activity, rate, effectiveDate } = req.body;
if (!contractorId || !rate || !effectiveDate) {
return res.status(400).json({ error: 'Missing required fields (contractorId, rate, effectiveDate)' });
}
// Verify contractor exists
const [contractors] = await db.query(
'SELECT * FROM users WHERE id = ? AND role = ?',
[contractorId, 'Contractor']
);
if (contractors.length === 0) {
return res.status(404).json({ error: 'Contractor not found' });
}
// Supervisors can only set rates for contractors in their department
if (req.user.role === 'Supervisor' && contractors[0].department_id !== req.user.departmentId) {
return res.status(403).json({ error: 'Contractor not in your department' });
}
const [result] = await db.query(
'INSERT INTO contractor_rates (contractor_id, sub_department_id, activity, rate, effective_date) VALUES (?, ?, ?, ?, ?)',
[contractorId, subDepartmentId || null, activity || null, rate, effectiveDate]
);
const [newRate] = await db.query(
`SELECT cr.*,
u.name as contractor_name, u.username as contractor_username,
sd.name as sub_department_name
FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
WHERE cr.id = ?`,
[result.insertId]
);
res.status(201).json(newRate[0]);
} catch (error) {
console.error('Set contractor rate error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update contractor rate
router.put('/:id', authenticateToken, authorize('Supervisor', 'SuperAdmin'), async (req, res) => {
try {
const { rate, activity, effectiveDate } = req.body;
const [existing] = await db.query('SELECT * FROM contractor_rates WHERE id = ?', [req.params.id]);
if (existing.length === 0) {
return res.status(404).json({ error: 'Rate not found' });
}
const updates = [];
const params = [];
if (rate !== undefined) {
updates.push('rate = ?');
params.push(rate);
}
if (activity !== undefined) {
updates.push('activity = ?');
params.push(activity);
}
if (effectiveDate !== undefined) {
updates.push('effective_date = ?');
params.push(effectiveDate);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
params.push(req.params.id);
await db.query(`UPDATE contractor_rates SET ${updates.join(', ')} WHERE id = ?`, params);
const [updatedRate] = await db.query(
`SELECT cr.*,
u.name as contractor_name, u.username as contractor_username,
sd.name as sub_department_name
FROM contractor_rates cr
JOIN users u ON cr.contractor_id = u.id
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
WHERE cr.id = ?`,
[req.params.id]
);
res.json(updatedRate[0]);
} catch (error) {
console.error('Update contractor rate error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete contractor rate
router.delete('/:id', authenticateToken, authorize('Supervisor', 'SuperAdmin'), async (req, res) => {
try {
const [existing] = await db.query('SELECT * FROM contractor_rates WHERE id = ?', [req.params.id]);
if (existing.length === 0) {
return res.status(404).json({ error: 'Rate not found' });
}
await db.query('DELETE FROM contractor_rates WHERE id = ?', [req.params.id]);
res.json({ message: 'Rate deleted successfully' });
} catch (error) {
console.error('Delete contractor rate error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -1,96 +0,0 @@
import express from 'express';
import db from '../config/database.js';
import { authenticateToken, authorize } from '../middleware/auth.js';
const router = express.Router();
// Get all departments
router.get('/', authenticateToken, async (req, res) => {
try {
const [departments] = await db.query('SELECT * FROM departments ORDER BY name');
res.json(departments);
} catch (error) {
console.error('Get departments error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get department by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const [departments] = await db.query('SELECT * FROM departments WHERE id = ?', [req.params.id]);
if (departments.length === 0) {
return res.status(404).json({ error: 'Department not found' });
}
res.json(departments[0]);
} catch (error) {
console.error('Get department error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get sub-departments by department ID
router.get('/:id/sub-departments', authenticateToken, async (req, res) => {
try {
const [subDepartments] = await db.query(
'SELECT * FROM sub_departments WHERE department_id = ? ORDER BY name',
[req.params.id]
);
res.json(subDepartments);
} catch (error) {
console.error('Get sub-departments error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Create department (SuperAdmin only)
router.post('/', authenticateToken, authorize('SuperAdmin'), async (req, res) => {
try {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Department name required' });
}
const [result] = await db.query('INSERT INTO departments (name) VALUES (?)', [name]);
const [newDepartment] = await db.query('SELECT * FROM departments WHERE id = ?', [result.insertId]);
res.status(201).json(newDepartment[0]);
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ error: 'Department already exists' });
}
console.error('Create department error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Create sub-department (SuperAdmin only)
router.post('/:id/sub-departments', authenticateToken, authorize('SuperAdmin'), async (req, res) => {
try {
const { name, primaryActivity } = req.body;
if (!name || !primaryActivity) {
return res.status(400).json({ error: 'Name and primary activity required' });
}
const [result] = await db.query(
'INSERT INTO sub_departments (department_id, name, primary_activity) VALUES (?, ?, ?)',
[req.params.id, name, primaryActivity]
);
const [newSubDepartment] = await db.query(
'SELECT * FROM sub_departments WHERE id = ?',
[result.insertId]
);
res.status(201).json(newSubDepartment[0]);
} catch (error) {
console.error('Create sub-department error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -1,236 +0,0 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import db from '../config/database.js';
import { authenticateToken, authorize } from '../middleware/auth.js';
const router = express.Router();
// Get all users (with filters)
router.get('/', authenticateToken, async (req, res) => {
try {
const { role, departmentId } = req.query;
let query = `
SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
u.contractor_id, u.is_active, u.created_at,
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 1=1
`;
const params = [];
// Supervisors can only see users in their department
if (req.user.role === 'Supervisor') {
query += ' AND u.department_id = ?';
params.push(req.user.departmentId);
}
if (role) {
query += ' AND u.role = ?';
params.push(role);
}
if (departmentId) {
query += ' AND u.department_id = ?';
params.push(departmentId);
}
query += ' ORDER BY u.created_at DESC';
const [users] = await db.query(query, params);
res.json(users);
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get user by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const [users] = await db.query(
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
u.contractor_id, u.is_active, u.created_at,
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 = ?`,
[req.params.id]
);
if (users.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
// Supervisors can only view users in their department
if (req.user.role === 'Supervisor' && users[0].department_id !== req.user.departmentId) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(users[0]);
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Create user
router.post('/', authenticateToken, authorize('SuperAdmin', 'Supervisor'), async (req, res) => {
try {
const { username, name, email, password, role, departmentId, contractorId } = req.body;
if (!username || !name || !email || !password || !role) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Supervisors can only create users in their department
if (req.user.role === 'Supervisor') {
if (departmentId !== req.user.departmentId) {
return res.status(403).json({ error: 'Can only create users in your department' });
}
if (role === 'SuperAdmin' || role === 'Supervisor') {
return res.status(403).json({ error: 'Cannot create admin or supervisor users' });
}
}
const hashedPassword = await bcrypt.hash(password, 10);
const [result] = await db.query(
'INSERT INTO users (username, name, email, password, role, department_id, contractor_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
[username, name, email, hashedPassword, role, departmentId || null, contractorId || null]
);
const [newUser] = await db.query(
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
u.contractor_id, u.is_active, u.created_at,
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]
);
res.status(201).json(newUser[0]);
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ error: 'Username or email already exists' });
}
console.error('Create user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update user
router.put('/:id', authenticateToken, authorize('SuperAdmin', 'Supervisor'), async (req, res) => {
try {
const { name, email, role, departmentId, contractorId, isActive } = req.body;
// Check if user exists
const [existingUsers] = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (existingUsers.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
// Supervisors can only update users in their department
if (req.user.role === 'Supervisor') {
if (existingUsers[0].department_id !== req.user.departmentId) {
return res.status(403).json({ error: 'Can only update users in your department' });
}
if (role === 'SuperAdmin' || role === 'Supervisor') {
return res.status(403).json({ error: 'Cannot modify admin or supervisor roles' });
}
}
const updates = [];
const params = [];
if (name !== undefined) {
updates.push('name = ?');
params.push(name);
}
if (email !== undefined) {
updates.push('email = ?');
params.push(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);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
params.push(req.params.id);
await db.query(
`UPDATE users SET ${updates.join(', ')} WHERE id = ?`,
params
);
const [updatedUser] = await db.query(
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
u.contractor_id, u.is_active, u.created_at,
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 = ?`,
[req.params.id]
);
res.json(updatedUser[0]);
} catch (error) {
console.error('Update user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete user
router.delete('/:id', authenticateToken, authorize('SuperAdmin', 'Supervisor'), async (req, res) => {
try {
const [users] = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (users.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
// Supervisors can only delete users in their department
if (req.user.role === 'Supervisor') {
if (users[0].department_id !== req.user.departmentId) {
return res.status(403).json({ error: 'Can only delete users in your department' });
}
if (users[0].role === 'SuperAdmin' || users[0].role === 'Supervisor') {
return res.status(403).json({ error: 'Cannot delete admin or supervisor users' });
}
}
await db.query('DELETE FROM users WHERE id = ?', [req.params.id]);
res.json({ message: 'User deleted successfully' });
} catch (error) {
console.error('Delete user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -1,244 +0,0 @@
import express from 'express';
import db from '../config/database.js';
import { authenticateToken, authorize } from '../middleware/auth.js';
const router = express.Router();
// Get all work allocations
router.get('/', authenticateToken, async (req, res) => {
try {
const { employeeId, status, departmentId } = req.query;
let query = `
SELECT wa.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
c.name as contractor_name,
sd.name as sub_department_name,
d.name as department_name
FROM work_allocations wa
JOIN users e ON wa.employee_id = e.id
JOIN users s ON wa.supervisor_id = s.id
JOIN users c ON wa.contractor_id = c.id
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
LEFT JOIN departments d ON e.department_id = d.id
WHERE 1=1
`;
const params = [];
// Role-based filtering
if (req.user.role === 'Supervisor') {
query += ' AND wa.supervisor_id = ?';
params.push(req.user.id);
} else if (req.user.role === 'Employee') {
query += ' AND wa.employee_id = ?';
params.push(req.user.id);
} else if (req.user.role === 'Contractor') {
query += ' AND wa.contractor_id = ?';
params.push(req.user.id);
}
if (employeeId) {
query += ' AND wa.employee_id = ?';
params.push(employeeId);
}
if (status) {
query += ' AND wa.status = ?';
params.push(status);
}
if (departmentId) {
query += ' AND e.department_id = ?';
params.push(departmentId);
}
query += ' ORDER BY wa.assigned_date DESC, wa.created_at DESC';
const [allocations] = await db.query(query, params);
res.json(allocations);
} catch (error) {
console.error('Get work allocations error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get work allocation by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const [allocations] = await db.query(
`SELECT wa.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
c.name as contractor_name,
sd.name as sub_department_name,
d.name as department_name
FROM work_allocations wa
JOIN users e ON wa.employee_id = e.id
JOIN users s ON wa.supervisor_id = s.id
JOIN users c ON wa.contractor_id = c.id
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
LEFT JOIN departments d ON e.department_id = d.id
WHERE wa.id = ?`,
[req.params.id]
);
if (allocations.length === 0) {
return res.status(404).json({ error: 'Work allocation not found' });
}
res.json(allocations[0]);
} catch (error) {
console.error('Get work allocation error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Create work allocation (Supervisor or SuperAdmin)
router.post('/', authenticateToken, authorize('Supervisor', 'SuperAdmin'), async (req, res) => {
try {
const { employeeId, contractorId, subDepartmentId, activity, description, assignedDate, rate, units, totalAmount, departmentId } = req.body;
if (!employeeId || !contractorId || !assignedDate) {
return res.status(400).json({ error: 'Missing required fields' });
}
// SuperAdmin can create for any department, Supervisor only for their own
let targetDepartmentId = req.user.role === 'SuperAdmin' ? departmentId : req.user.departmentId;
// Verify employee exists (SuperAdmin can assign any employee, Supervisor only their department)
let employeeQuery = 'SELECT * FROM users WHERE id = ?';
let employeeParams = [employeeId];
if (req.user.role === 'Supervisor') {
employeeQuery += ' AND department_id = ?';
employeeParams.push(req.user.departmentId);
}
const [employees] = await db.query(employeeQuery, employeeParams);
if (employees.length === 0) {
return res.status(403).json({ error: 'Employee not found or not in your department' });
}
// Use provided rate or get contractor's current rate
let finalRate = rate;
if (!finalRate) {
const [rates] = await db.query(
'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 [result] = await db.query(
`INSERT INTO work_allocations
(employee_id, supervisor_id, contractor_id, sub_department_id, activity, description, assigned_date, rate, units, total_amount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[employeeId, req.user.id, contractorId, subDepartmentId || null, activity || null, description || null, assignedDate, finalRate, units || null, totalAmount || null]
);
const [newAllocation] = await db.query(
`SELECT wa.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
c.name as contractor_name,
sd.name as sub_department_name,
d.name as department_name
FROM work_allocations wa
JOIN users e ON wa.employee_id = e.id
JOIN users s ON wa.supervisor_id = s.id
JOIN users c ON wa.contractor_id = c.id
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
LEFT JOIN departments d ON e.department_id = d.id
WHERE wa.id = ?`,
[result.insertId]
);
res.status(201).json(newAllocation[0]);
} catch (error) {
console.error('Create work allocation error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update work allocation status (Supervisor or SuperAdmin)
router.put('/:id/status', authenticateToken, authorize('Supervisor', 'SuperAdmin'), async (req, res) => {
try {
const { status, completionDate } = req.body;
if (!status) {
return res.status(400).json({ error: 'Status required' });
}
// SuperAdmin can update any allocation, Supervisor only their own
let query = 'SELECT * FROM work_allocations WHERE id = ?';
let params = [req.params.id];
if (req.user.role === 'Supervisor') {
query += ' AND supervisor_id = ?';
params.push(req.user.id);
}
const [allocations] = await db.query(query, params);
if (allocations.length === 0) {
return res.status(403).json({ error: 'Work allocation not found or access denied' });
}
await db.query(
'UPDATE work_allocations SET status = ?, completion_date = ? WHERE id = ?',
[status, completionDate || null, req.params.id]
);
const [updatedAllocation] = await db.query(
`SELECT wa.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
c.name as contractor_name,
sd.name as sub_department_name,
d.name as department_name
FROM work_allocations wa
JOIN users e ON wa.employee_id = e.id
JOIN users s ON wa.supervisor_id = s.id
JOIN users c ON wa.contractor_id = c.id
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
LEFT JOIN departments d ON e.department_id = d.id
WHERE wa.id = ?`,
[req.params.id]
);
res.json(updatedAllocation[0]);
} catch (error) {
console.error('Update work allocation error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete work allocation (Supervisor or SuperAdmin)
router.delete('/:id', authenticateToken, authorize('Supervisor', 'SuperAdmin'), async (req, res) => {
try {
// SuperAdmin can delete any allocation, Supervisor only their own
let query = 'SELECT * FROM work_allocations WHERE id = ?';
let params = [req.params.id];
if (req.user.role === 'Supervisor') {
query += ' AND supervisor_id = ?';
params.push(req.user.id);
}
const [allocations] = await db.query(query, params);
if (allocations.length === 0) {
return res.status(403).json({ error: 'Work allocation not found or access denied' });
}
await db.query('DELETE FROM work_allocations WHERE id = ?', [req.params.id]);
res.json({ message: 'Work allocation deleted successfully' });
} catch (error) {
console.error('Delete work allocation error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -1,13 +0,0 @@
import bcrypt from 'bcryptjs';
const password = process.argv[2] || 'admin123';
bcrypt.hash(password, 10, (err, hash) => {
if (err) {
console.error('Error hashing password:', err);
process.exit(1);
}
console.log('Password:', password);
console.log('Hash:', hash);
console.log('\nUse this hash in the database schema or when creating users.');
});

View File

@@ -1,57 +0,0 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import departmentRoutes from './routes/departments.js';
import workAllocationRoutes from './routes/work-allocations.js';
import attendanceRoutes from './routes/attendance.js';
import contractorRateRoutes from './routes/contractor-rates.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/departments', departmentRoutes);
app.use('/api/work-allocations', workAllocationRoutes);
app.use('/api/attendance', attendanceRoutes);
app.use('/api/contractor-rates', contractorRateRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(err.status || 500).json({
error: err.message || 'Internal server error'
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
console.log(`📊 Health check: http://localhost:${PORT}/health`);
});

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
mysql: mysql:
image: mysql:8.0 image: mysql:8.0
@@ -9,12 +7,21 @@ services:
MYSQL_ROOT_PASSWORD: admin123 MYSQL_ROOT_PASSWORD: admin123
MYSQL_DATABASE: work_allocation MYSQL_DATABASE: work_allocation
ports: ports:
- "3306:3306" - "3307:3306"
volumes: volumes:
- 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,10 +1,10 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>my-dashboard</title> <title>WorkAllocation</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

113
package-lock.json generated
View File

@@ -8,10 +8,11 @@
"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"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -1769,6 +1770,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -1995,6 +2005,19 @@
} }
] ]
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2054,6 +2077,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2090,6 +2122,18 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true "dev": true
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2648,6 +2692,15 @@
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -3220,6 +3273,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3720,6 +3774,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4105,6 +4171,24 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4113,6 +4197,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,10 +10,11 @@
"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"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

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,32 +1,62 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider } from "./contexts/AuthContext.tsx";
import { Sidebar } from './components/layout/Sidebar'; import { useAuth } from "./contexts/authContext.ts";
import { Header } from './components/layout/Header'; import { Sidebar } from "./components/layout/Sidebar.tsx";
import { DashboardPage } from './pages/DashboardPage'; import { Header } from "./components/layout/Header.tsx";
import { UsersPage } from './pages/UsersPage'; import { DashboardPage } from "./pages/DashboardPage.tsx";
import { WorkAllocationPage } from './pages/WorkAllocationPage'; import { UsersPage } from "./pages/UsersPage.tsx";
import { AttendancePage } from './pages/AttendancePage'; import { WorkAllocationPage } from "./pages/WorkAllocationPage.tsx";
import { RatesPage } from './pages/RatesPage'; import { AttendancePage } from "./pages/AttendancePage.tsx";
import { LoginPage } from './pages/LoginPage'; import { RatesPage } from "./pages/RatesPage.tsx";
import { EmployeeSwapPage } from "./pages/EmployeeSwapPage.tsx";
import { LoginPage } from "./pages/LoginPage.tsx";
import { ReportingPage } from "./pages/ReportingPage.tsx";
import { StandardRatesPage } from "./pages/StandardRatesPage.tsx";
import { AllRatesPage } from "./pages/AllRatesPage.tsx";
import { ActivitiesPage } from "./pages/ActivitiesPage.tsx";
import { ContractorPaymentPage } from "./pages/ContractorPaymentPage.tsx";
type PageType = 'dashboard' | 'users' | 'allocation' | 'attendance' | 'rates'; type PageType =
| "dashboard"
| "users"
| "allocation"
| "attendance"
| "rates"
| "swaps"
| "reports"
| "standard-rates"
| "all-rates"
| "activities"
| "contractor-payment";
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":
return <EmployeeSwapPage />;
case "reports":
return <ReportingPage />;
case "standard-rates":
return <StandardRatesPage />;
case "all-rates":
return <AllRatesPage />;
case "activities":
return <ActivitiesPage />;
case "contractor-payment":
return <ContractorPaymentPage />;
default: default:
return <DashboardPage />; return <DashboardPage />;
} }
@@ -37,7 +67,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>
@@ -52,11 +83,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>
@@ -73,4 +107,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,7 +1,24 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from "react";
import { Bell, LogOut, X, Camera, Shield, User, Mail, Building2, ChevronDown, ChevronUp } from 'lucide-react'; import {
import { useAuth } from '../../contexts/AuthContext'; Bell,
import { useDepartments } from '../../hooks/useDepartments'; Building2,
Camera,
ChevronDown,
ChevronUp,
CreditCard,
FileText,
Landmark,
LogOut,
Mail,
Phone,
Shield,
User,
X,
} from "lucide-react";
import { useAuth } from "../../contexts/authContext.ts";
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;
@@ -10,78 +27,100 @@ 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);
const [showDetails, setShowDetails] = useState(false);
const [fullUserData, setFullUserData] = useState<UserType | null>(null);
// Fetch full user details when popup opens
useEffect(() => {
if (isOpen && user?.id) {
api.getUser(user.id).then(setFullUserData).catch(console.error);
}
}, [isOpen, user?.id]);
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 isContractor = user?.role === "Contractor";
return ( return (
<div className="absolute right-4 top-16 w-[380px] 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"> <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">
{/* Header */} {/* Header */}
<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 type="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>
@@ -94,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>
@@ -104,24 +145,139 @@ 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>
)} )}
{/* Personal & Bank Details Section - for Employee and Contractor */}
{isEmployeeOrContractor && (
<button type="button"
onClick={() => setShowDetails(!showDetails)}
className="w-full flex items-center justify-between p-3 bg-teal-50 hover:bg-teal-100 rounded-xl transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-teal-100 rounded-full flex items-center justify-center">
<CreditCard size={18} className="text-teal-600" />
</div>
<div className="text-left">
<p className="text-xs text-gray-500 font-medium">
Personal & Bank Details
</p>
<p className="text-sm font-semibold text-gray-800">
View your information
</p>
</div>
</div>
{showDetails
? <ChevronUp size={18} className="text-teal-600" />
: <ChevronDown size={18} className="text-teal-600" />}
</button>
)}
{showDetails && isEmployeeOrContractor && fullUserData && (
<div className="bg-teal-50 rounded-xl p-4 border border-teal-200 space-y-4">
{/* Personal Details */}
<div>
<h4 className="font-semibold text-teal-800 mb-2 flex items-center gap-2">
<Phone size={14} /> Personal Details
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Phone Number</span>
<span className="font-medium text-gray-800">
{fullUserData.phone_number || "Not provided"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Aadhar Number</span>
<span className="font-medium text-gray-800">
{fullUserData.aadhar_number
? `XXXX-XXXX-${fullUserData.aadhar_number.slice(-4)}`
: "Not provided"}
</span>
</div>
</div>
</div>
{/* Bank Details */}
<div>
<h4 className="font-semibold text-teal-800 mb-2 flex items-center gap-2">
<Landmark size={14} /> Bank Details
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Bank Name</span>
<span className="font-medium text-gray-800">
{fullUserData.bank_name || "Not provided"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Account Number</span>
<span className="font-medium text-gray-800">
{fullUserData.bank_account_number
? `XXXX${fullUserData.bank_account_number.slice(-4)}`
: "Not provided"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">IFSC Code</span>
<span className="font-medium text-gray-800">
{fullUserData.bank_ifsc || "Not provided"}
</span>
</div>
</div>
</div>
{/* Contractor-specific Details */}
{isContractor && (
<div>
<h4 className="font-semibold text-teal-800 mb-2 flex items-center gap-2">
<FileText size={14} /> Contractor Details
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Agreement No.</span>
<span className="font-medium text-gray-800">
{fullUserData.contractor_agreement_number ||
"Not provided"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">PF Number</span>
<span className="font-medium text-gray-800">
{fullUserData.pf_number || "Not provided"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">ESIC Number</span>
<span className="font-medium text-gray-800">
{fullUserData.esic_number || "Not provided"}
</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Permissions Section */} {/* Permissions Section */}
<button <button type="button"
onClick={() => setShowPermissions(!showPermissions)} onClick={() => setShowPermissions(!showPermissions)}
className="w-full flex items-center justify-between p-3 bg-amber-50 hover:bg-amber-100 rounded-xl transition-colors" className="w-full flex items-center justify-between p-3 bg-amber-50 hover:bg-amber-100 rounded-xl transition-colors"
> >
@@ -130,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>
@@ -154,7 +321,7 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
{/* Sign Out Button */} {/* Sign Out Button */}
<div className="px-6 pb-4"> <div className="px-6 pb-4">
<button <button type="button"
onClick={onLogout} onClick={onLogout}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors font-medium" className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors font-medium"
> >
@@ -179,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 type="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 type="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,19 @@
import React from 'react'; import React from "react";
import { LayoutDashboard, Users, Briefcase, CalendarCheck, DollarSign, ClipboardList } from 'lucide-react'; import {
import { useAuth } from '../../contexts/AuthContext'; ArrowRightLeft,
Briefcase,
CalendarCheck,
ClipboardList,
CreditCard,
DollarSign,
Eye,
FileSpreadsheet,
Layers,
LayoutDashboard,
Scale,
Users,
} from "lucide-react";
import { useAuth } from "../../contexts/authContext.ts";
interface SidebarItemProps { interface SidebarItemProps {
icon: React.ElementType; icon: React.ElementType;
@@ -9,13 +22,15 @@ interface SidebarItemProps {
onClick: () => void; onClick: () => void;
} }
const SidebarItem: React.FC<SidebarItemProps> = ({ icon: Icon, label, active, onClick }) => ( const SidebarItem: React.FC<SidebarItemProps> = (
<button { icon: Icon, label, active, onClick },
) => (
<button type="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,7 +45,16 @@ 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 canManageRates = user?.role === 'SuperAdmin' || user?.role === 'Supervisor'; const isSuperAdmin = user?.role === "SuperAdmin";
const isSupervisor = user?.role === "Supervisor";
const isContractor = user?.role === "Contractor";
const isEmployee = user?.role === "Employee";
// Role-based access
const canManageUsers = isSuperAdmin || isSupervisor;
const canManageAllocations = isSuperAdmin || isSupervisor;
const canManageAttendance = isSuperAdmin || isSupervisor;
const canManageRates = isSuperAdmin || isSupervisor;
return ( return (
<div className="w-64 bg-[#1e293b] flex flex-col"> <div className="w-64 bg-[#1e293b] flex flex-col">
@@ -40,45 +64,144 @@ 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>
</div> </div>
<nav className="flex-1 py-4"> <nav className="flex-1 py-4">
{/* Dashboard - visible to all */}
<SidebarItem <SidebarItem
icon={LayoutDashboard} icon={LayoutDashboard}
label="Dashboard" label="Dashboard"
active={activePage === 'dashboard'} active={activePage === "dashboard"}
onClick={() => onNavigate('dashboard')} onClick={() => onNavigate("dashboard")}
/>
<SidebarItem
icon={Users}
label="User Management"
active={activePage === 'users'}
onClick={() => onNavigate('users')}
/>
<SidebarItem
icon={Briefcase}
label="Work Allocation"
active={activePage === 'allocation'}
onClick={() => onNavigate('allocation')}
/>
<SidebarItem
icon={CalendarCheck}
label="Attendance"
active={activePage === 'attendance'}
onClick={() => onNavigate('attendance')}
/> />
{/* User Management - SuperAdmin and Supervisor only */}
{canManageUsers && (
<SidebarItem
icon={Users}
label="User Management"
active={activePage === "users"}
onClick={() => onNavigate("users")}
/>
)}
{/* Work Allocation - SuperAdmin and Supervisor only */}
{canManageAllocations && (
<SidebarItem
icon={Briefcase}
label="Work Allocation"
active={activePage === "allocation"}
onClick={() => onNavigate("allocation")}
/>
)}
{/* Attendance - SuperAdmin and Supervisor only */}
{canManageAttendance && (
<SidebarItem
icon={CalendarCheck}
label="Attendance"
active={activePage === "attendance"}
onClick={() => onNavigate("attendance")}
/>
)}
{/* 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 */}
{isSuperAdmin && (
<SidebarItem
icon={ArrowRightLeft}
label="Employee Swap"
active={activePage === "swaps"}
onClick={() => onNavigate("swaps")}
/>
)}
{/* Reports - SuperAdmin and Supervisor */}
{canManageRates && (
<SidebarItem
icon={FileSpreadsheet}
label="Reports"
active={activePage === "reports"}
onClick={() => onNavigate("reports")}
/>
)}
{/* Standard Rates - SuperAdmin and Supervisor */}
{canManageRates && (
<SidebarItem
icon={Scale}
label="Standard Rates"
active={activePage === "standard-rates"}
onClick={() => onNavigate("standard-rates")}
/>
)}
{/* All Rates View - SuperAdmin only */}
{isSuperAdmin && (
<SidebarItem
icon={Eye}
label="All Rates"
active={activePage === "all-rates"}
onClick={() => onNavigate("all-rates")}
/>
)}
{/* Activities Management - SuperAdmin and Supervisor */}
{(isSuperAdmin || isSupervisor) && (
<SidebarItem
icon={Layers}
label="Activities"
active={activePage === "activities"}
onClick={() => onNavigate("activities")}
/>
)}
{/* Contractor Payment Report - SuperAdmin only */}
{isSuperAdmin && (
<SidebarItem
icon={CreditCard}
label="Contractor Payment"
active={activePage === "contractor-payment"}
onClick={() => onNavigate("contractor-payment")}
/> />
)} )}
</nav> </nav>
{/* Role indicator at bottom */}
<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-sm font-medium ${
isSuperAdmin
? "text-purple-400"
: isSupervisor
? "text-blue-400"
: isContractor
? "text-orange-400"
: isEmployee
? "text-green-400"
: "text-gray-400"
}`}
>
{user?.role || "Unknown"}
</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,4 +1,5 @@
import React, { InputHTMLAttributes } from 'react'; import React, { InputHTMLAttributes, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string; label?: string;
@@ -6,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 && (
@@ -16,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}
/> />
@@ -26,6 +31,50 @@ export const Input: React.FC<InputProps> = ({ label, error, required, className
); );
}; };
interface PasswordInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
error?: string;
required?: boolean;
}
export const PasswordInput: React.FC<PasswordInputProps> = (
{ label, error, required, className = "", disabled, ...props },
) => {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-2">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
<div className="relative">
<input
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 ${
error ? "border-red-500" : ""
} ${
disabled ? "bg-gray-100 text-gray-600 cursor-not-allowed" : ""
} ${className}`}
disabled={disabled}
{...props}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
};
interface SelectProps extends InputHTMLAttributes<HTMLSelectElement> { interface SelectProps extends InputHTMLAttributes<HTMLSelectElement> {
label?: string; label?: string;
error?: string; error?: string;
@@ -33,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 && (
@@ -43,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}
> >
@@ -66,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 && (
@@ -77,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,77 +1,57 @@
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 {
user: User | null;
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.ts";
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

@@ -0,0 +1,74 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../services/api.ts";
import { Activity } from "../types.ts";
export const useActivities = (subDepartmentId?: string | number) => {
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchActivities = useCallback(async () => {
// Don't fetch if no subDepartmentId - return empty array
if (!subDepartmentId) {
setActivities([]);
return;
}
setLoading(true);
setError(null);
try {
const data = await api.getActivities({
subDepartmentId: Number(subDepartmentId),
});
setActivities(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to fetch activities",
);
setActivities([]);
} finally {
setLoading(false);
}
}, [subDepartmentId]);
useEffect(() => {
fetchActivities();
}, [fetchActivities]);
return { activities, loading, error, refresh: fetchActivities };
};
export const useActivitiesByDepartment = (departmentId?: string | number) => {
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchActivities = useCallback(async () => {
if (!departmentId) {
setActivities([]);
return;
}
setLoading(true);
setError(null);
try {
const data = await api.getActivities({
departmentId: Number(departmentId),
});
setActivities(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to fetch activities",
);
setActivities([]);
} finally {
setLoading(false);
}
}, [departmentId]);
useEffect(() => {
fetchActivities();
}, [fetchActivities]);
return { activities, loading, error, refresh: fetchActivities };
};

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,

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.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,

View File

@@ -0,0 +1,528 @@
import React, { useEffect, useState } from "react";
import {
Activity as ActivityIcon,
Layers,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import { Card, CardContent, CardHeader } 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 { useDepartments, useSubDepartments } from "../hooks/useDepartments.ts";
import { useActivitiesByDepartment } from "../hooks/useActivities.ts";
import { useAuth } from "../contexts/authContext.ts";
import { api } from "../services/api.ts";
import { Activity, SubDepartment } from "../types.ts";
export const ActivitiesPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<"subDepartments" | "activities">(
"subDepartments",
);
const { user } = useAuth();
const { departments } = useDepartments();
// Role-based access
const isSupervisor = user?.role === "Supervisor";
const isSuperAdmin = user?.role === "SuperAdmin";
const canAccess = isSupervisor || isSuperAdmin;
// Department selection - supervisors are locked to their department
const [selectedDeptId, setSelectedDeptId] = useState<string>("");
// Get sub-departments and activities for selected department
const { subDepartments, refresh: refreshSubDepts } = useSubDepartments(
selectedDeptId,
);
const { activities, refresh: refreshActivities } = useActivitiesByDepartment(
selectedDeptId,
);
// Form states
const [subDeptForm, setSubDeptForm] = useState({ name: "" });
const [activityForm, setActivityForm] = useState({
subDepartmentId: "",
name: "",
unitOfMeasurement: "Per Bag" as "Per Bag" | "Fixed Rate-Per Person",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
// Auto-select department for supervisors
useEffect(() => {
if (isSupervisor && user?.department_id) {
setSelectedDeptId(String(user.department_id));
}
}, [isSupervisor, user?.department_id]);
// Clear messages after 3 seconds
useEffect(() => {
if (success || error) {
const timer = setTimeout(() => {
setSuccess("");
setError("");
}, 3000);
return () => clearTimeout(timer);
}
}, [success, error]);
const handleCreateSubDepartment = async () => {
if (!subDeptForm.name.trim()) {
setError("Sub-department name is required");
return;
}
if (!selectedDeptId) {
setError("Please select a department first");
return;
}
setLoading(true);
setError("");
try {
await api.createSubDepartment({
department_id: parseInt(selectedDeptId),
name: subDeptForm.name.trim(),
});
setSuccess("Sub-department created successfully");
setSubDeptForm({ name: "" });
refreshSubDepts();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create sub-department",
);
} finally {
setLoading(false);
}
};
const handleDeleteSubDepartment = async (id: number) => {
if (
!confirm(
"Are you sure you want to delete this sub-department? This will also delete all associated activities.",
)
) {
return;
}
setLoading(true);
try {
await api.deleteSubDepartment(id);
setSuccess("Sub-department deleted successfully");
refreshSubDepts();
refreshActivities();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to delete sub-department",
);
} finally {
setLoading(false);
}
};
const handleCreateActivity = async () => {
if (!activityForm.name.trim()) {
setError("Activity name is required");
return;
}
if (!activityForm.subDepartmentId) {
setError("Please select a sub-department");
return;
}
setLoading(true);
setError("");
try {
await api.createActivity({
sub_department_id: parseInt(activityForm.subDepartmentId),
name: activityForm.name.trim(),
unit_of_measurement: activityForm.unitOfMeasurement,
});
setSuccess("Activity created successfully");
setActivityForm({
subDepartmentId: "",
name: "",
unitOfMeasurement: "Per Bag",
});
refreshActivities();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create activity",
);
} finally {
setLoading(false);
}
};
const handleDeleteActivity = async (id: number) => {
if (!confirm("Are you sure you want to delete this activity?")) {
return;
}
setLoading(true);
try {
await api.deleteActivity(id);
setSuccess("Activity deleted successfully");
refreshActivities();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to delete activity",
);
} finally {
setLoading(false);
}
};
if (!canAccess) {
return (
<div className="p-6">
<Card>
<CardContent>
<p className="text-red-600">
You do not have permission to access this page.
</p>
</CardContent>
</Card>
</div>
);
}
const selectedDeptName =
departments.find((d) => d.id === parseInt(selectedDeptId))?.name || "";
return (
<div className="p-6 space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Layers className="h-6 w-6 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-800">
Manage Activities & Sub-Departments
</h2>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
refreshSubDepts();
refreshActivities();
}}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{/* Department Selection */}
<div className="mb-6">
{isSupervisor
? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Department
</label>
<Input value={selectedDeptName || "Loading..."} disabled />
<p className="text-xs text-gray-500 mt-1">
As a supervisor, you can only manage your department's
activities.
</p>
</div>
)
: (
<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>
{/* Messages */}
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-100 text-green-700 rounded-md">
{success}
</div>
)}
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="flex space-x-8">
<button
onClick={() => setActiveTab("subDepartments")}
className={`py-3 px-1 border-b-2 font-medium text-sm ${
activeTab === "subDepartments"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Layers className="h-4 w-4 inline mr-2" />
Sub-Departments ({subDepartments.length})
</button>
<button
onClick={() => setActiveTab("activities")}
className={`py-3 px-1 border-b-2 font-medium text-sm ${
activeTab === "activities"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<ActivityIcon className="h-4 w-4 inline mr-2" />
Activities ({activities.length})
</button>
</div>
</div>
{!selectedDeptId
? (
<p className="text-gray-500 text-center py-8">
Please select a department to manage sub-departments and
activities.
</p>
)
: (
<>
{/* Sub-Departments Tab */}
{activeTab === "subDepartments" && (
<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>
<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>
{/* Sub-Departments List */}
<Table>
<TableHeader>
<TableHead>Sub-Department Name</TableHead>
<TableHead>Activities Count</TableHead>
<TableHead>Created At</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableHeader>
<TableBody>
{subDepartments.length === 0
? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-gray-500 py-8"
>
No sub-departments found. Create one above.
</TableCell>
</TableRow>
)
: (
subDepartments.map((subDept: SubDepartment) => {
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>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
)}
{/* Activities Tab */}
{activeTab === "activities" && (
<div className="space-y-6">
{/* Create Activity Form */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-md font-semibold text-gray-700 mb-4">
Add New Activity
</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<Select
label="Sub-Department"
value={activityForm.subDepartmentId}
onChange={(e) =>
setActivityForm((prev) => ({
...prev,
subDepartmentId: e.target.value,
}))}
options={[
{ value: "", label: "Select Sub-Department" },
...subDepartments.map((s) => ({
value: String(s.id),
label: s.name,
})),
]}
/>
<Input
label="Activity Name"
value={activityForm.name}
onChange={(e) =>
setActivityForm((prev) => ({
...prev,
name: e.target.value,
}))}
placeholder="e.g., Mufali Aavak Katai"
/>
<Select
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"
>
No activities found. Create one above.
</TableCell>
</TableRow>
)
: (
activities.map((activity: Activity) => (
<TableRow key={activity.id}>
<TableCell className="font-medium">
{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>
</Card>
</div>
);
};
export default ActivitiesPage;

358
src/pages/AllRatesPage.tsx Normal file
View File

@@ -0,0 +1,358 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Calendar, Eye, Filter, RefreshCw, Search } 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 } from "../hooks/useDepartments.ts";
import { useAuth } from "../contexts/authContext.ts";
export const AllRatesPage: React.FC = () => {
const { user } = useAuth();
const { departments } = useDepartments();
const [allRates, setAllRates] = useState<any[]>([]);
const [summary, setSummary] = useState<
{
totalContractorRates: number;
totalStandardRates: number;
totalRates: number;
} | null
>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
// Filters
const [filters, setFilters] = useState({
departmentId: "",
startDate: "",
endDate: "",
rateType: "", // 'contractor' | 'standard' | ''
});
const isSuperAdmin = user?.role === "SuperAdmin";
// Fetch all rates
const fetchAllRates = useCallback(async () => {
setLoading(true);
setError("");
try {
const params: any = {};
if (filters.departmentId) {
params.departmentId = parseInt(filters.departmentId);
}
if (filters.startDate) params.startDate = filters.startDate;
if (filters.endDate) params.endDate = filters.endDate;
const data = await api.getAllRates(params);
setAllRates(data.allRates);
setSummary(data.summary);
} catch (err: any) {
setError(err.message || "Failed to fetch rates");
} finally {
setLoading(false);
}
}, [filters.departmentId, filters.startDate, filters.endDate]);
useEffect(() => {
if (isSuperAdmin) {
fetchAllRates();
}
}, [isSuperAdmin, fetchAllRates]);
const handleFilterChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFilters((prev) => ({ ...prev, [name]: value }));
};
const applyFilters = () => {
fetchAllRates();
};
const clearFilters = () => {
setFilters({
departmentId: "",
startDate: "",
endDate: "",
rateType: "",
});
setTimeout(fetchAllRates, 0);
};
// Filter rates based on search and rate type
const filteredRates = useMemo(() => {
let rates = allRates;
// Filter by rate type
if (filters.rateType) {
rates = rates.filter((r) => r.rate_type === filters.rateType);
}
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
rates = rates.filter((r) =>
r.contractor_name?.toLowerCase().includes(query) ||
r.sub_department_name?.toLowerCase().includes(query) ||
r.department_name?.toLowerCase().includes(query) ||
r.activity?.toLowerCase().includes(query) ||
r.created_by_name?.toLowerCase().includes(query)
);
}
return rates;
}, [allRates, searchQuery, filters.rateType]);
// Access check
if (!isSuperAdmin) {
return (
<div className="p-6">
<Card>
<CardContent>
<div className="text-center py-12">
<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>
<p className="text-gray-500">
This page is only accessible to Super Admin accounts.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6">
<Card>
<div className="border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Eye className="text-purple-600" size={24} />
<div>
<h2 className="text-xl font-semibold text-gray-800">
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>
<CardContent>
{/* Filters */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-2 mb-4">
<Filter size={18} className="text-gray-500" />
<h3 className="font-medium text-gray-700">Filters</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Select
label="Department"
name="departmentId"
value={filters.departmentId}
onChange={handleFilterChange}
options={[
{ value: "", label: "All Departments" },
...departments.map((d) => ({
value: String(d.id),
label: d.name,
})),
]}
/>
<Select
label="Rate Type"
name="rateType"
value={filters.rateType}
onChange={handleFilterChange}
options={[
{ value: "", label: "All Types" },
{ value: "contractor", label: "Contractor Rates" },
{ value: "standard", label: "Standard Rates" },
]}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<Input
type="date"
name="startDate"
value={filters.startDate}
onChange={handleFilterChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<Input
type="date"
name="endDate"
value={filters.endDate}
onChange={handleFilterChange}
/>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button onClick={applyFilters} size="sm">
Apply Filters
</Button>
<Button variant="primary" onClick={clearFilters} size="sm">
Clear
</Button>
</div>
</div>
{/* Summary Cards */}
{summary && (
<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="text-sm text-blue-600 font-medium">
Total Rates
</div>
<div className="text-2xl font-bold text-blue-800">
{summary.totalRates}
</div>
</div>
<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-2xl font-bold text-orange-800">
{summary.totalContractorRates}
</div>
</div>
<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-2xl font-bold text-green-800">
{summary.totalStandardRates}
</div>
</div>
</div>
)}
{/* Search and Refresh */}
<div className="flex gap-4 mb-4">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
size={18}
/>
<input
type="text"
placeholder="Search by contractor, department, activity..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<Button variant="ghost" onClick={fetchAllRates}>
<RefreshCw size={16} className="mr-2" />
Refresh
</Button>
</div>
{/* Error */}
{error && (
<div className="text-center py-4 text-red-600 bg-red-50 rounded-md mb-4">
Error: {error}
</div>
)}
{/* Table */}
{loading
? <div className="text-center py-8">Loading all rates...</div>
: filteredRates.length > 0
? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableHead>Type</TableHead>
<TableHead>Contractor</TableHead>
<TableHead>Department</TableHead>
<TableHead>Sub-Department</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Rate ()</TableHead>
<TableHead>Effective Date</TableHead>
<TableHead>Created By</TableHead>
</TableHeader>
<TableBody>
{filteredRates.map((rate, idx) => (
<TableRow key={`${rate.rate_type}-${rate.id}-${idx}`}>
<TableCell>
<span
className={`px-2 py-1 rounded text-xs font-medium ${
rate.rate_type === "contractor"
? "bg-orange-100 text-orange-700"
: "bg-green-100 text-green-700"
}`}
>
{rate.rate_type === "contractor"
? "Contractor"
: "Standard"}
</span>
</TableCell>
<TableCell className="font-medium">
{rate.contractor_name || "-"}
</TableCell>
<TableCell>{rate.department_name || "-"}</TableCell>
<TableCell>{rate.sub_department_name || "-"}</TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded text-xs font-medium ${
rate.activity === "Loading" ||
rate.activity === "Unloading"
? "bg-purple-100 text-purple-700"
: "bg-gray-100 text-gray-700"
}`}
>
{rate.activity || "Standard"}
</span>
</TableCell>
<TableCell>
<span className="text-green-600 font-semibold">
{rate.rate}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Calendar size={14} className="text-gray-400" />
{new Date(rate.effective_date).toLocaleDateString()}
</div>
</TableCell>
<TableCell className="text-gray-500">
{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>
</Card>
</div>
);
};

View File

@@ -1,37 +1,67 @@
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 } 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,
Edit2,
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.ts";
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 [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState(''); const [sortField, setSortField] = useState<"date" | "employee" | "status">(
const [sortField, setSortField] = useState<'date' | 'employee' | 'status'>('date'); "date",
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); );
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [editingRecord, setEditingRecord] = useState<number | null>(null);
const [editStatus, setEditStatus] = useState<AttendanceStatus>("CheckedIn");
const [editRemark, setEditRemark] = useState("");
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);
} }
@@ -41,31 +71,17 @@ export const AttendancePage: React.FC = () => {
fetchAttendance(); fetchAttendance();
}, []); }, []);
// Check employee status when selected
useEffect(() => {
if (selectedEmployee && workDate) {
const record = attendance.find(
a => a.employee_id === parseInt(selectedEmployee) &&
a.work_date?.split('T')[0] === workDate
);
setEmployeeStatus(record || null);
} else {
setEmployeeStatus(null);
}
}, [selectedEmployee, workDate, attendance]);
const handleCheckIn = async () => { const handleCheckIn = async () => {
if (!selectedEmployee) { if (!selectedEmployee) {
alert('Please select an employee'); alert("Please select an employee/contractor");
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' });
} 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);
} }
@@ -73,72 +89,137 @@ 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/contractor");
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' });
} 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);
} }
}; };
const employeeOptions = [ const handleMarkAbsent = async () => {
{ value: '', label: 'Select Employee' }, if (!selectedEmployee) {
...employees.filter(e => e.role === 'Employee').map(e => ({ alert("Please select an employee/contractor");
value: String(e.id), return;
label: `${e.name} (${e.username})` }
})) setCheckInLoading(true);
try {
await api.markAbsent(
parseInt(selectedEmployee),
workDate,
"Marked absent by supervisor",
);
await fetchAttendance();
} catch (err: any) {
alert(err.message || "Failed to mark absent");
} finally {
setCheckInLoading(false);
}
};
const handleUpdateStatus = async (recordId: number) => {
try {
await api.updateAttendanceStatus(recordId, editStatus, editRemark);
await fetchAttendance();
setEditingRecord(null);
setEditRemark("");
} catch (err: any) {
alert(err.message || "Failed to update status");
}
};
const startEditing = (record: any) => {
setEditingRecord(record.id);
setEditStatus(record.status);
setEditRemark(record.remark || "");
};
const cancelEditing = () => {
setEditingRecord(null);
setEditRemark("");
};
const canEditAttendance = user?.role === "SuperAdmin" ||
user?.role === "Supervisor";
// Include both Employees and Contractors for attendance
const attendanceUserOptions = [
{ value: "", label: "Select Employee/Contractor" },
...employees
.filter((e) => e.role === "Employee" || e.role === "Contractor")
.map((e) => ({
value: String(e.id),
label: `${e.name} (${e.role})`,
})),
]; ];
// Get all attendance records for selected user on selected date
const userDayRecords = useMemo(() => {
if (!selectedEmployee || !workDate) return [];
return attendance.filter(
(a) =>
a.employee_id === parseInt(selectedEmployee) &&
a.work_date?.split("T")[0] === workDate
);
}, [selectedEmployee, workDate, attendance]);
// Check if user has an active (not checked out) session
const hasActiveCheckIn = userDayRecords.some((r) => r.status === "CheckedIn");
// 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" />;
}; };
@@ -149,21 +230,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
@@ -172,11 +253,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..."
@@ -190,7 +274,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>
@@ -201,89 +285,194 @@ 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")}
</TableHeader> className="flex items-center hover:text-blue-600 transition-colors"
<TableBody> >
{filteredAndSortedAttendance.map((record) => ( Status <SortIcon field="status" />
<TableRow key={record.id}> </button>
<TableCell>{record.id}</TableCell> </TableHead>
<TableCell>{record.employee_name || '-'}</TableCell> <TableHead>Remark</TableHead>
<TableCell>{new Date(record.work_date).toLocaleDateString()}</TableCell> {canEditAttendance && <TableHead>Actions</TableHead>}
<TableCell> </TableHeader>
{record.check_in_time <TableBody>
? new Date(record.check_in_time).toLocaleTimeString() {filteredAndSortedAttendance.map((record) => (
: '-'} <TableRow key={record.id}>
</TableCell> <TableCell>{record.id}</TableCell>
<TableCell> <TableCell>{record.employee_name || "-"}</TableCell>
{record.check_out_time <TableCell>
? new Date(record.check_out_time).toLocaleTimeString() {new Date(record.work_date).toLocaleDateString()}
: '-'} </TableCell>
</TableCell> <TableCell>
<TableCell> {record.check_in_time
<span className={`px-2 py-1 rounded text-xs font-medium ${ ? new Date(record.check_in_time)
record.status === 'CheckedOut' ? 'bg-green-100 text-green-700' : .toLocaleTimeString()
record.status === 'CheckedIn' ? 'bg-blue-100 text-blue-700' : : "-"}
'bg-gray-100 text-gray-700' </TableCell>
}`}> <TableCell>
{record.status === 'CheckedOut' ? 'Completed' : {record.check_out_time
record.status === 'CheckedIn' ? 'Checked In' : record.status} ? new Date(record.check_out_time)
</span> .toLocaleTimeString()
</TableCell> : "-"}
</TableRow> </TableCell>
))} <TableCell>
</TableBody> {editingRecord === record.id
</Table> ? (
) : ( <select
<div className="text-center py-8 text-gray-500"> value={editStatus}
{searchQuery ? 'No matching records found' : 'No attendance records found'} onChange={(e) =>
</div> 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 and contractor attendance. Multiple check-ins per day are supported.
</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">
<Select <Select
label="Select Employee" label="Select Employee/Contractor"
value={selectedEmployee} value={selectedEmployee}
onChange={(e) => setSelectedEmployee(e.target.value)} onChange={(e) => setSelectedEmployee(e.target.value)}
options={employeeOptions} options={attendanceUserOptions}
/> />
<Input <Input
label="Work Date" label="Work Date"
@@ -294,57 +483,120 @@ export const AttendancePage: React.FC = () => {
</div> </div>
{selectedEmployee && ( {selectedEmployee && (
<div className={`border rounded-md p-4 flex items-start ${ <div className="space-y-3">
employeeStatus?.status === 'CheckedIn' {/* Current Status */}
? 'bg-blue-50 border-blue-200' <div
: employeeStatus?.status === 'CheckedOut' className={`border rounded-md p-4 flex items-start ${
? 'bg-green-50 border-green-200' hasActiveCheckIn
: 'bg-yellow-50 border-yellow-200' ? "bg-blue-50 border-blue-200"
}`}> : userDayRecords.length > 0
{employeeStatus?.status === 'CheckedIn' ? ( ? "bg-green-50 border-green-200"
<> : "bg-yellow-50 border-yellow-200"
<Clock size={20} className="text-blue-600 mr-2 flex-shrink-0 mt-0.5" /> }`}
<p className="text-sm text-blue-800"> >
Employee is currently checked in. Check-in time: { {hasActiveCheckIn ? (
employeeStatus.check_in_time <>
? new Date(employeeStatus.check_in_time).toLocaleTimeString() <Clock
: 'N/A' size={20}
} className="text-blue-600 mr-2 flex-shrink-0 mt-0.5"
</p> />
</> <p className="text-sm text-blue-800">
) : employeeStatus?.status === 'CheckedOut' ? ( Currently checked in. Please check out before checking in again.
<> </p>
<CheckCircle size={20} className="text-green-600 mr-2 flex-shrink-0 mt-0.5" /> </>
<p className="text-sm text-green-800"> ) : userDayRecords.length > 0 ? (
Employee has completed attendance for this date. <>
</p> <CheckCircle
</> size={20}
) : ( className="text-green-600 mr-2 flex-shrink-0 mt-0.5"
<> />
<AlertTriangle size={20} className="text-yellow-600 mr-2 flex-shrink-0 mt-0.5" /> <p className="text-sm text-green-800">
<p className="text-sm text-yellow-800">Employee has not checked in for this date</p> {userDayRecords.length} attendance record(s) for this date. Can check in again.
</> </p>
</>
) : (
<>
<AlertTriangle
size={20}
className="text-yellow-600 mr-2 flex-shrink-0 mt-0.5"
/>
<p className="text-sm text-yellow-800">
No attendance records for this date
</p>
</>
)}
</div>
{/* Today's Records */}
{userDayRecords.length > 0 && (
<div className="bg-gray-50 rounded-md p-3">
<h4 className="text-sm font-medium text-gray-700 mb-2">
Today's Sessions ({userDayRecords.length})
</h4>
<div className="space-y-2">
{userDayRecords.map((record, idx) => (
<div
key={record.id}
className="flex items-center justify-between text-sm bg-white rounded px-3 py-2 border"
>
<span className="text-gray-600">Session {idx + 1}</span>
<div className="flex items-center gap-4">
<span className="text-gray-500">
In: {record.check_in_time
? new Date(record.check_in_time).toLocaleTimeString()
: "-"}
</span>
<span className="text-gray-500">
Out: {record.check_out_time
? new Date(record.check_out_time).toLocaleTimeString()
: "-"}
</span>
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
record.status === "CheckedOut"
? "bg-green-100 text-green-700"
: record.status === "CheckedIn"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-700"
}`}
>
{record.status === "CheckedOut" ? "Completed" : record.status}
</span>
</div>
</div>
))}
</div>
</div>
)} )}
</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'} disabled={checkInLoading || !selectedEmployee || hasActiveCheckIn}
> >
<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="secondary"
onClick={handleCheckOut} onClick={handleCheckOut}
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status !== 'CheckedIn'} disabled={checkInLoading || !selectedEmployee || !hasActiveCheckIn}
> >
<LogOut size={16} className="mr-2" /> <LogOut size={16} className="mr-2" />
{checkInLoading ? 'Processing...' : 'Check Out'} {checkInLoading ? "Processing..." : "Check Out"}
</Button>
<Button
size="lg"
variant="danger"
onClick={handleMarkAbsent}
disabled={checkInLoading || !selectedEmployee || hasActiveCheckIn || userDayRecords.length > 0}
>
<UserX size={16} className="mr-2" />
{checkInLoading ? "Processing..." : "Mark Absent"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,576 @@
import React, { useMemo, useState } from "react";
import { Download, FileSpreadsheet, Printer, RefreshCw } from "lucide-react";
import { Card, CardContent } from "../components/ui/Card.tsx";
import { Button } from "../components/ui/Button.tsx";
import { api } from "../services/api.ts";
import { useDepartments } from "../hooks/useDepartments.ts";
import * as XLSX from "xlsx";
interface ContractorPaymentData {
contractor_id: number;
contractor_name: string;
as_per_contractor: number;
dana: number;
tukdi: number;
groundnut: number;
commission_salary: number;
total: number;
tds_base_amount: number;
payable_before_deduction: number;
security_deduction: number;
advance: number;
final_payable: number;
excess_short: number;
}
interface SubDepartmentTotals {
sub_department_id: number;
sub_department_name: string;
contractors: ContractorPaymentData[];
subTotal: ContractorPaymentData;
}
export const ContractorPaymentPage: React.FC = () => {
const { departments } = useDepartments();
const [selectedDepartment, setSelectedDepartment] = useState<string>("");
const [startDate, setStartDate] = useState(() => {
const date = new Date();
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-01`;
});
const [endDate, setEndDate] = useState(() => {
const date = new Date();
return date.toISOString().split("T")[0];
});
const [loading, setLoading] = useState(false);
const [reportData, setReportData] = useState<SubDepartmentTotals[]>([]);
const [companyName, setCompanyName] = useState("DUSAD AGROFOOD PVT LTD");
// Fetch report data
const fetchReport = async () => {
if (!selectedDepartment) {
alert("Please select a department");
return;
}
setLoading(true);
try {
// Fetch completed allocations for the date range
const params: Record<string, any> = {
startDate,
endDate,
departmentId: parseInt(selectedDepartment),
};
const data = await api.getCompletedAllocationsReport(params);
// Process data to group by sub-department and contractor
const processedData = processReportData(data.allocations);
setReportData(processedData);
} catch (err: any) {
console.error("Failed to fetch report:", err);
alert(err.message || "Failed to fetch report");
} finally {
setLoading(false);
}
};
// Process raw allocation data into the payment report format
const processReportData = (allocations: any[]): SubDepartmentTotals[] => {
const subDeptMap = new Map<number, Map<number, ContractorPaymentData>>();
const subDeptNames = new Map<number, string>();
allocations.forEach((alloc) => {
const subDeptId = alloc.sub_department_id || 0;
const contractorId = alloc.contractor_id || 0;
const subDeptName = alloc.sub_department_name || "Other";
subDeptNames.set(subDeptId, subDeptName);
if (!subDeptMap.has(subDeptId)) {
subDeptMap.set(subDeptId, new Map());
}
const contractorMap = subDeptMap.get(subDeptId)!;
if (!contractorMap.has(contractorId)) {
contractorMap.set(contractorId, {
contractor_id: contractorId,
contractor_name: alloc.contractor_name || "Unknown",
as_per_contractor: 0,
dana: 0,
tukdi: 0,
groundnut: 0,
commission_salary: 0,
total: 0,
tds_base_amount: 0,
payable_before_deduction: 0,
security_deduction: 0,
advance: 0,
final_payable: 0,
excess_short: 0,
});
}
const contractor = contractorMap.get(contractorId)!;
const amount = parseFloat(alloc.total_amount) || parseFloat(alloc.rate) || 0;
const activity = (alloc.activity || "").toLowerCase();
// Categorize by activity type
if (activity.includes("dana")) {
contractor.dana += amount;
} else if (activity.includes("tukdi")) {
contractor.tukdi += amount;
} else if (activity.includes("groundnut")) {
contractor.groundnut += amount;
} else if (activity.includes("commission") || activity.includes("salary")) {
contractor.commission_salary += amount;
} else {
contractor.as_per_contractor += amount;
}
});
// Calculate totals for each contractor
const result: SubDepartmentTotals[] = [];
subDeptMap.forEach((contractorMap, subDeptId) => {
const contractors: ContractorPaymentData[] = [];
const subTotal: ContractorPaymentData = {
contractor_id: 0,
contractor_name: "Sub Total",
as_per_contractor: 0,
dana: 0,
tukdi: 0,
groundnut: 0,
commission_salary: 0,
total: 0,
tds_base_amount: 0,
payable_before_deduction: 0,
security_deduction: 0,
advance: 0,
final_payable: 0,
excess_short: 0,
};
contractorMap.forEach((contractor) => {
// Calculate derived values
contractor.total = contractor.as_per_contractor + contractor.dana +
contractor.tukdi + contractor.groundnut + contractor.commission_salary;
contractor.tds_base_amount = Math.round(contractor.total * 0.01); // 1% TDS
contractor.payable_before_deduction = contractor.total - contractor.tds_base_amount;
contractor.security_deduction = Math.round(contractor.total * 0.0035); // 0.35% security
contractor.final_payable = contractor.payable_before_deduction -
contractor.security_deduction - contractor.advance;
contractor.excess_short = contractor.as_per_contractor - contractor.final_payable;
// Add to subtotal
subTotal.as_per_contractor += contractor.as_per_contractor;
subTotal.dana += contractor.dana;
subTotal.tukdi += contractor.tukdi;
subTotal.groundnut += contractor.groundnut;
subTotal.commission_salary += contractor.commission_salary;
subTotal.total += contractor.total;
subTotal.tds_base_amount += contractor.tds_base_amount;
subTotal.payable_before_deduction += contractor.payable_before_deduction;
subTotal.security_deduction += contractor.security_deduction;
subTotal.advance += contractor.advance;
subTotal.final_payable += contractor.final_payable;
subTotal.excess_short += contractor.excess_short;
contractors.push(contractor);
});
result.push({
sub_department_id: subDeptId,
sub_department_name: subDeptNames.get(subDeptId) || "Other",
contractors,
subTotal,
});
});
return result;
};
// Format date for display
const formatDateRange = () => {
const start = new Date(startDate);
const end = new Date(endDate);
return `${start.toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" }).replace(/\//g, ".")} TO ${end.toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" }).replace(/\//g, ".")}`;
};
// Export to Excel
const exportToExcel = () => {
if (reportData.length === 0) {
alert("No data to export");
return;
}
const wb = XLSX.utils.book_new();
const wsData: any[][] = [];
// Header rows
wsData.push([companyName]);
wsData.push([formatDateRange()]);
wsData.push(["ALL CONTRACTOR PAYMENT SHEET"]);
wsData.push([]);
// Column headers
wsData.push([
"S.NO", "NAME CONTRACTOR", "As per Contractor", "", "", "", "", "",
"TDS @1%", "Payable", "SECURITY", "ADVANCE", "Final Payable", "Excess/Short"
]);
wsData.push([
"", "", "ALL", "DANA", "TUKDI", "GROUNDNUT", "Commission & Salary", "Total",
"(BASE AMOUNT)", "(Before deduction)", "DEDUCTION 0.35%", "", "", ""
]);
let serialNo = 1;
reportData.forEach((subDept) => {
// Sub-department header
wsData.push([subDept.sub_department_name.toUpperCase()]);
subDept.contractors.forEach((contractor) => {
wsData.push([
serialNo++,
contractor.contractor_name,
contractor.as_per_contractor || "",
contractor.dana || "",
contractor.tukdi || "",
contractor.groundnut || "",
contractor.commission_salary || "",
contractor.total || "",
contractor.tds_base_amount || "",
contractor.payable_before_deduction || "",
contractor.security_deduction || "",
contractor.advance || "",
contractor.final_payable || "",
contractor.excess_short || "",
]);
});
// Sub Total row
wsData.push([
"",
"Sub Total",
subDept.subTotal.as_per_contractor,
subDept.subTotal.dana,
subDept.subTotal.tukdi,
subDept.subTotal.groundnut,
subDept.subTotal.commission_salary,
subDept.subTotal.total,
subDept.subTotal.tds_base_amount,
subDept.subTotal.payable_before_deduction,
subDept.subTotal.security_deduction,
subDept.subTotal.advance,
subDept.subTotal.final_payable,
subDept.subTotal.excess_short,
]);
});
// Footer
wsData.push([]);
wsData.push(["CONTRACTOR", "", "", "", "", "", "CHECKER", "", "", "", "", "", "AUTHORISED AUTHORITY"]);
const ws = XLSX.utils.aoa_to_sheet(wsData);
// Set column widths
ws["!cols"] = [
{ wch: 5 }, // S.NO
{ wch: 30 }, // NAME CONTRACTOR
{ wch: 12 }, // ALL
{ wch: 10 }, // DANA
{ wch: 10 }, // TUKDI
{ wch: 12 }, // GROUNDNUT
{ wch: 14 }, // Commission
{ wch: 12 }, // Total
{ wch: 12 }, // TDS
{ wch: 12 }, // Payable
{ wch: 12 }, // Security
{ wch: 10 }, // Advance
{ wch: 14 }, // Final Payable
{ wch: 12 }, // Excess/Short
];
XLSX.utils.book_append_sheet(wb, ws, "Payment Report");
const filename = `contractor_payment_${startDate}_to_${endDate}.xlsx`;
XLSX.writeFile(wb, filename);
};
// Print report
const printReport = () => {
window.print();
};
// Calculate grand totals
const grandTotal = useMemo(() => {
const total: ContractorPaymentData = {
contractor_id: 0,
contractor_name: "Grand Total",
as_per_contractor: 0,
dana: 0,
tukdi: 0,
groundnut: 0,
commission_salary: 0,
total: 0,
tds_base_amount: 0,
payable_before_deduction: 0,
security_deduction: 0,
advance: 0,
final_payable: 0,
excess_short: 0,
};
reportData.forEach((subDept) => {
total.as_per_contractor += subDept.subTotal.as_per_contractor;
total.dana += subDept.subTotal.dana;
total.tukdi += subDept.subTotal.tukdi;
total.groundnut += subDept.subTotal.groundnut;
total.commission_salary += subDept.subTotal.commission_salary;
total.total += subDept.subTotal.total;
total.tds_base_amount += subDept.subTotal.tds_base_amount;
total.payable_before_deduction += subDept.subTotal.payable_before_deduction;
total.security_deduction += subDept.subTotal.security_deduction;
total.advance += subDept.subTotal.advance;
total.final_payable += subDept.subTotal.final_payable;
total.excess_short += subDept.subTotal.excess_short;
});
return total;
}, [reportData]);
return (
<div className="p-6">
<Card>
<div className="border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileSpreadsheet className="text-green-600" size={24} />
<h2 className="text-xl font-semibold text-gray-800">
Contractor Payment Report
</h2>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={printReport} disabled={reportData.length === 0}>
<Printer size={16} className="mr-2" />
Print
</Button>
<Button onClick={exportToExcel} disabled={reportData.length === 0}>
<Download size={16} className="mr-2" />
Export to Excel
</Button>
</div>
</div>
</div>
<CardContent>
{/* Filters */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
<input
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
<select
value={selectedDepartment}
onChange={(e) => setSelectedDepartment(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
>
<option value="">Select Department</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.id}>{dept.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="mt-4">
<Button onClick={fetchReport} disabled={loading}>
<RefreshCw size={16} className={`mr-2 ${loading ? "animate-spin" : ""}`} />
{loading ? "Generating..." : "Generate Report"}
</Button>
</div>
</div>
{/* Report Table */}
{reportData.length > 0 && (
<div className="overflow-x-auto print:overflow-visible" id="payment-report">
<div className="min-w-[1200px]">
{/* Report Header */}
<div className="text-center mb-4 bg-yellow-300 py-2">
<h1 className="text-lg font-bold text-black">{companyName}</h1>
<p className="text-sm font-semibold">{formatDateRange()}</p>
<p className="text-base font-bold">ALL CONTRACTOR PAYMENT SHEET</p>
</div>
{/* Table */}
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-yellow-300">
<th rowSpan={2} className="border border-black px-2 py-1 text-left">S.NO</th>
<th rowSpan={2} className="border border-black px-2 py-1 text-left">NAME CONTRACTOR</th>
<th colSpan={6} className="border border-black px-2 py-1 text-center">As per DAFPL</th>
<th rowSpan={2} className="border border-black px-2 py-1 text-center">TDS @1%<br/>(BASE AMOUNT)</th>
<th rowSpan={2} className="border border-black px-2 py-1 text-center">Payable<br/>(Before deduction)</th>
<th rowSpan={2} className="border border-black px-2 py-1 text-center">SECURITY<br/>DEDUCTION 0.35%</th>
<th rowSpan={2} className="border border-black px-2 py-1 text-center">ADVANCE</th>
<th rowSpan={2} className="border border-black px-2 py-1 text-center">Final Payable</th>
<th rowSpan={2} className="border border-black px-2 py-1 text-center">Excess/Short</th>
</tr>
<tr className="bg-yellow-300">
<th className="border border-black px-2 py-1 text-center">ALL</th>
<th className="border border-black px-2 py-1 text-center">DANA</th>
<th className="border border-black px-2 py-1 text-center">TUKDI</th>
<th className="border border-black px-2 py-1 text-center">GROUNDNUT</th>
<th className="border border-black px-2 py-1 text-center">Commission<br/>& Salary</th>
<th className="border border-black px-2 py-1 text-center">Total</th>
</tr>
</thead>
<tbody>
{reportData.map((subDept, subDeptIdx) => (
<React.Fragment key={subDept.sub_department_id}>
{/* Sub-department header */}
<tr className="bg-yellow-300">
<td colSpan={14} className="border border-black px-2 py-1 font-bold">
{subDept.sub_department_name.toUpperCase()}
</td>
</tr>
{/* Contractors */}
{subDept.contractors.map((contractor, idx) => {
const rowNum = reportData
.slice(0, subDeptIdx)
.reduce((acc, sd) => acc + sd.contractors.length, 0) + idx + 1;
return (
<tr key={contractor.contractor_id} className="hover:bg-gray-50">
<td className="border border-black px-2 py-1">{rowNum}</td>
<td className="border border-black px-2 py-1 font-medium">{contractor.contractor_name}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.as_per_contractor || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.dana || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.tukdi || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.groundnut || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.commission_salary || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.total || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.tds_base_amount || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.payable_before_deduction || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.security_deduction || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.advance || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.final_payable || ""}</td>
<td className="border border-black px-2 py-1 text-right">{contractor.excess_short || ""}</td>
</tr>
);
})}
{/* Sub Total */}
<tr className="bg-yellow-300 font-bold">
<td className="border border-black px-2 py-1"></td>
<td className="border border-black px-2 py-1">Sub Total</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.as_per_contractor}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.dana}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.tukdi}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.groundnut}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.commission_salary}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.total}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.tds_base_amount}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.payable_before_deduction}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.security_deduction}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.advance}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.final_payable}</td>
<td className="border border-black px-2 py-1 text-right">{subDept.subTotal.excess_short}</td>
</tr>
</React.Fragment>
))}
{/* Grand Total */}
<tr className="bg-yellow-400 font-bold text-base">
<td className="border border-black px-2 py-2"></td>
<td className="border border-black px-2 py-2">GRAND TOTAL</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.as_per_contractor}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.dana}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.tukdi}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.groundnut}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.commission_salary}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.total}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.tds_base_amount}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.payable_before_deduction}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.security_deduction}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.advance}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.final_payable}</td>
<td className="border border-black px-2 py-2 text-right">{grandTotal.excess_short}</td>
</tr>
</tbody>
</table>
{/* Footer */}
<div className="mt-8 flex justify-between text-sm font-medium border-t border-black pt-4">
<div className="text-center">
<div className="border-t border-black w-40 pt-2">CONTRACTOR</div>
</div>
<div className="text-center">
<div className="border-t border-black w-40 pt-2">CHECKER</div>
</div>
<div className="text-center">
<div className="border-t border-black w-48 pt-2">AUTHORISED AUTHORITY</div>
</div>
</div>
</div>
</div>
)}
{reportData.length === 0 && !loading && (
<div className="text-center py-12 text-gray-500">
<FileSpreadsheet size={48} className="mx-auto mb-4 text-gray-300" />
<p>Select a department and date range, then click "Generate Report"</p>
</div>
)}
</CardContent>
</Card>
{/* Print Styles */}
<style>{`
@media print {
body * {
visibility: hidden;
}
#payment-report, #payment-report * {
visibility: visible;
}
#payment-report {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.no-print {
display: none !important;
}
}
`}</style>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,707 @@
import React, { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
ArrowRightLeft,
Building2,
CheckCircle,
Clock,
Filter,
Plus,
RefreshCw,
Search,
User,
XCircle,
} 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 { useDepartments } from "../hooks/useDepartments.ts";
import type { EmployeeSwap, SwapReason, SwapStatus } from "../types.ts";
export const EmployeeSwapPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<"list" | "create">("list");
const [swaps, setSwaps] = useState<EmployeeSwap[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<SwapStatus | "">("");
const { employees } = useEmployees();
const { departments } = useDepartments();
// Form state
const [formData, setFormData] = useState({
employeeId: "",
targetDepartmentId: "",
targetContractorId: "",
swapReason: "" as SwapReason | "",
reasonDetails: "",
workCompletionPercentage: 0,
swapDate: new Date().toISOString().split("T")[0],
});
const [submitting, setSubmitting] = useState(false);
const fetchSwaps = useCallback(async () => {
setLoading(true);
setError("");
try {
const params: { status?: string } = {};
if (statusFilter) params.status = statusFilter;
const data = await api.getEmployeeSwaps(params);
setSwaps(data);
} catch (err: any) {
setError(err.message || "Failed to fetch swaps");
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchSwaps();
}, [fetchSwaps]);
const handleCreateSwap = async (e: React.FormEvent) => {
e.preventDefault();
if (
!formData.employeeId || !formData.targetDepartmentId ||
!formData.swapReason
) {
alert("Please fill in all required fields");
return;
}
setSubmitting(true);
try {
await api.createEmployeeSwap({
employeeId: parseInt(formData.employeeId),
targetDepartmentId: parseInt(formData.targetDepartmentId),
targetContractorId: formData.targetContractorId
? parseInt(formData.targetContractorId)
: undefined,
swapReason: formData.swapReason as SwapReason,
reasonDetails: formData.reasonDetails || undefined,
workCompletionPercentage: formData.workCompletionPercentage,
swapDate: formData.swapDate,
});
// Reset form and switch to list
setFormData({
employeeId: "",
targetDepartmentId: "",
targetContractorId: "",
swapReason: "",
reasonDetails: "",
workCompletionPercentage: 0,
swapDate: new Date().toISOString().split("T")[0],
});
setActiveTab("list");
await fetchSwaps();
} catch (err: any) {
alert(err.message || "Failed to create swap");
} finally {
setSubmitting(false);
}
};
const handleCompleteSwap = async (id: number) => {
if (
!confirm("Complete this swap and return employee to original department?")
) return;
try {
await api.completeEmployeeSwap(id);
await fetchSwaps();
} catch (err: any) {
alert(err.message || "Failed to complete swap");
}
};
const handleCancelSwap = async (id: number) => {
if (
!confirm("Cancel this swap and return employee to original department?")
) return;
try {
await api.cancelEmployeeSwap(id);
await fetchSwaps();
} catch (err: any) {
alert(err.message || "Failed to cancel swap");
}
};
// Filter employees (only show employees)
const employeeList = employees.filter((e) => e.role === "Employee");
// Get contractors for selected target department
const targetContractors = employees.filter(
(e) =>
e.role === "Contractor" &&
e.department_id === parseInt(formData.targetDepartmentId),
);
// Get selected employee details
const selectedEmployee = employeeList.find((e) =>
e.id === parseInt(formData.employeeId)
);
// Filter swaps based on search
const filteredSwaps = swaps.filter((swap) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
swap.employee_name?.toLowerCase().includes(query) ||
swap.original_department_name?.toLowerCase().includes(query) ||
swap.target_department_name?.toLowerCase().includes(query)
);
});
const getStatusBadge = (status: SwapStatus) => {
switch (status) {
case "Active":
return (
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700">
Active
</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 colors: Record<SwapReason, string> = {
"LeftWork": "bg-orange-100 text-orange-700",
"Sick": "bg-red-100 text-red-700",
"FinishedEarly": "bg-green-100 text-green-700",
"Other": "bg-gray-100 text-gray-700",
};
const labels: Record<SwapReason, string> = {
"LeftWork": "Left Work",
"Sick": "Sick",
"FinishedEarly": "Finished Early",
"Other": "Other",
};
return (
<span
className={`px-2 py-1 rounded text-xs font-medium ${colors[reason]}`}
>
{labels[reason]}
</span>
);
};
return (
<div className="p-6">
<Card>
{/* Header */}
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<ArrowRightLeft className="text-purple-600" size={24} />
</div>
<div>
<h1 className="text-xl font-bold text-gray-800">
Employee Work Swap
</h1>
<p className="text-sm text-gray-500">
Transfer employees between departments temporarily
</p>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<div className="flex space-x-8 px-6">
<button
onClick={() => setActiveTab("list")}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === "list"
? "border-purple-500 text-purple-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Swap History
</button>
<button
onClick={() => setActiveTab("create")}
className={`py-4 px-2 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === "create"
? "border-purple-500 text-purple-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Plus size={16} />
New Swap
</button>
</div>
</div>
<CardContent>
{activeTab === "list" && (
<div>
{/* Filters */}
<div className="flex gap-4 mb-6">
<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}
/>
<input
type="text"
placeholder="Search by employee or department..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-2">
<Filter size={18} className="text-gray-400" />
<select
value={statusFilter}
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"
>
<option value="">All Status</option>
<option value="Active">Active</option>
<option value="Completed">Completed</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<Button variant="ghost" onClick={fetchSwaps}>
<RefreshCw size={16} className="mr-2" />
Refresh
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center gap-2 text-blue-600 mb-1">
<Clock size={18} />
<span className="text-sm font-medium">Active</span>
</div>
<div className="text-2xl font-bold text-blue-700">
{swaps.filter((s) => s.status === "Active").length}
</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center gap-2 text-green-600 mb-1">
<CheckCircle size={18} />
<span className="text-sm font-medium">Completed</span>
</div>
<div className="text-2xl font-bold text-green-700">
{swaps.filter((s) => s.status === "Completed").length}
</div>
</div>
<div className="bg-red-50 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-600 mb-1">
<XCircle size={18} />
<span className="text-sm font-medium">Cancelled</span>
</div>
<div className="text-2xl font-bold text-red-700">
{swaps.filter((s) => s.status === "Cancelled").length}
</div>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<div className="flex items-center gap-2 text-purple-600 mb-1">
<ArrowRightLeft size={18} />
<span className="text-sm font-medium">Total Swaps</span>
</div>
<div className="text-2xl font-bold text-purple-700">
{swaps.length}
</div>
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{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>
<span className="ml-2 text-gray-600">Loading swaps...</span>
</div>
)
: filteredSwaps.length > 0
? (
<Table>
<TableHeader>
<TableHead>Employee</TableHead>
<TableHead>From To</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Completion %</TableHead>
<TableHead>Swap Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableHeader>
<TableBody>
{filteredSwaps.map((swap) => (
<TableRow key={swap.id}>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
<User size={16} className="text-purple-600" />
</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>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="text-gray-600">
{swap.original_department_name}
</span>
<ArrowRightLeft
size={14}
className="text-gray-400"
/>
<span className="font-medium text-purple-600">
{swap.target_department_name}
</span>
</div>
{swap.target_contractor_name && (
<div className="text-xs text-gray-500 mt-1">
New contractor: {swap.target_contractor_name}
</div>
)}
</TableCell>
<TableCell>
<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>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-16 bg-gray-200 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full"
style={{
width:
`${swap.work_completion_percentage}%`,
}}
/>
</div>
<span className="text-sm text-gray-600">
{swap.work_completion_percentage}%
</span>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-600">
{new Date(swap.swap_date).toLocaleDateString()}
</div>
<div className="text-xs text-gray-400">
by {swap.swapped_by_name}
</div>
</TableCell>
<TableCell>{getStatusBadge(swap.status)}</TableCell>
<TableCell>
{swap.status === "Active" && (
<div className="flex gap-1">
<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>
)}
</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>
)}
{activeTab === "create" && (
<div className="max-w-3xl mx-auto">
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-800">
Create Employee Swap
</h2>
<p className="text-sm text-gray-500">
Transfer an employee to a different department temporarily
</p>
</div>
<form onSubmit={handleCreateSwap} className="space-y-6">
{/* Employee Selection */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<User size={16} />
Select Employee
</h3>
<Select
label="Employee"
value={formData.employeeId}
onChange={(e) =>
setFormData({ ...formData, employeeId: e.target.value })}
options={[
{ value: "", label: "Select an employee..." },
...employeeList.map((e) => ({
value: String(e.id),
label: `${e.name} - ${e.department_name || "No Dept"}`,
})),
]}
required
/>
{selectedEmployee && (
<div className="mt-3 p-3 bg-white rounded border border-gray-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<User size={20} className="text-purple-600" />
</div>
<div>
<div className="font-medium text-gray-800">
{selectedEmployee.name}
</div>
<div className="text-sm text-gray-500">
Current: {selectedEmployee.department_name ||
"No Department"}
{selectedEmployee.contractor_name &&
` • Under: ${selectedEmployee.contractor_name}`}
</div>
</div>
</div>
</div>
)}
</div>
{/* Target Department */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Building2 size={16} />
Target Department
</h3>
<div className="grid grid-cols-2 gap-4">
<Select
label="Department"
value={formData.targetDepartmentId}
onChange={(e) =>
setFormData({
...formData,
targetDepartmentId: e.target.value,
targetContractorId: "", // Reset contractor when department changes
})}
options={[
{ value: "", label: "Select department..." },
...departments
.filter((d) =>
d.id !== selectedEmployee?.department_id
)
.map((d) => ({ value: String(d.id), label: d.name })),
]}
required
/>
<Select
label="Assign to Contractor (Optional)"
value={formData.targetContractorId}
onChange={(e) =>
setFormData({
...formData,
targetContractorId: e.target.value,
})}
options={[
{ value: "", label: selectedEmployee?.contractor_name
? `Keep Original (${selectedEmployee.contractor_name})`
: "Keep Original Contractor" },
...targetContractors.map((c) => ({
value: String(c.id),
label: c.name,
})),
]}
disabled={!formData.targetDepartmentId}
/>
</div>
</div>
{/* Swap Reason */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<AlertCircle size={16} />
Swap Reason
</h3>
<div className="grid grid-cols-2 gap-4">
<Select
label="Reason"
value={formData.swapReason}
onChange={(e) =>
setFormData({
...formData,
swapReason: e.target.value as SwapReason,
})}
options={[
{ value: "", label: "Select reason..." },
{ value: "LeftWork", label: "Left Work Early" },
{ value: "Sick", label: "Sick / Unwell" },
{
value: "FinishedEarly",
label: "Finished Work Early",
},
{ value: "Other", label: "Other Reason" },
]}
required
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Work Completion %
</label>
<div className="flex items-center gap-3">
<input
type="range"
min="0"
max="100"
step="5"
value={formData.workCompletionPercentage}
onChange={(e) =>
setFormData({
...formData,
workCompletionPercentage: parseInt(
e.target.value,
),
})}
className="flex-1"
/>
<span className="text-sm font-medium text-gray-700 w-12">
{formData.workCompletionPercentage}%
</span>
</div>
</div>
</div>
<div className="mt-4">
<Input
label="Additional Details (Optional)"
value={formData.reasonDetails}
onChange={(e) =>
setFormData({
...formData,
reasonDetails: e.target.value,
})}
placeholder="Provide more context about the swap..."
/>
</div>
</div>
{/* Swap Date */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Clock size={16} />
Swap Date
</h3>
<Input
label="Date"
type="date"
value={formData.swapDate}
onChange={(e) =>
setFormData({ ...formData, swapDate: e.target.value })}
required
/>
</div>
{/* Submit */}
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setActiveTab("list")}
>
Cancel
</Button>
<Button
type="submit"
disabled={submitting || !formData.employeeId ||
!formData.targetDepartmentId || !formData.swapReason}
>
{submitting
? (
<>
<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
</>
)}
</Button>
</div>
</form>
</div>
)}
</CardContent>
</Card>
</div>
);
};

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.ts";
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>
@@ -202,16 +224,11 @@ export const LoginPage: React.FC = () => {
<div className="w-full border-t border-white/10" /> <div className="w-full border-t border-white/10" />
</div> </div>
<div className="relative flex justify-center text-xs"> <div className="relative flex justify-center text-xs">
<span className="px-4 bg-transparent text-blue-200/50">Work Allocation System</span> <span className="px-4 bg-transparent text-blue-200/50"></span>
</div> </div>
</div> </div>
{/* Footer Info */} {/* Footer Info */}
<div className="text-center">
<p className="text-blue-200/40 text-xs">
Secure login powered by JWT authentication
</p>
</div>
</div> </div>
{/* Version badge */} {/* Version badge */}
@@ -222,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} />
@@ -239,106 +260,120 @@ 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 */}
<button <button type="button"
onClick={closeForgotModal} onClick={closeForgotModal}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors" className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
> >
<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 type="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 type="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; }
@@ -346,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,35 +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 { 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, useSubDepartments } from "../hooks/useDepartments.ts";
import { useActivities } from "../hooks/useActivities.ts";
import { useAuth } from "../contexts/authContext.ts";
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 [formError, setFormError] = useState(''); const { activities } = useActivities(formData.subDepartmentId);
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);
@@ -37,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);
} }
@@ -51,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);
} }
}; };
@@ -65,37 +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;
setFormData(prev => ({ ...prev, [name]: value }));
setFormError(''); // Auto-select department when contractor is selected
if (name === "contractorId" && value) {
const selectedContractor = contractors.find((c) =>
String(c.id) === value
);
if (selectedContractor?.department_id) {
setSelectedDept(String(selectedContractor.department_id));
// Clear sub-department and activity when contractor changes
setFormData((prev) => ({
...prev,
[name]: value,
subDepartmentId: "",
activity: "",
}));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
} // Clear activity when sub-department changes
else if (name === "subDepartmentId") {
setFormData((prev) => ({ ...prev, [name]: value, activity: "" }));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
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) {
@@ -107,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,
@@ -115,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);
} }
@@ -127,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)
@@ -165,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..."
@@ -208,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>
@@ -219,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.activity === 'Loading' || rate.activity === 'Unloading'
? '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.activity === 'Loading' || rate.activity === 'Unloading'
? '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>Loading/Unloading:</strong> Total = Units × Rate per Unit</li> <li>
<li><strong>Standard/Other:</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>
@@ -322,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"
@@ -350,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
@@ -359,18 +439,33 @@ export const RatesPage: React.FC = () => {
name="activity" name="activity"
value={formData.activity} value={formData.activity}
onChange={handleInputChange} onChange={handleInputChange}
disabled={!formData.subDepartmentId}
options={[ options={[
{ value: '', label: 'Select Activity (Optional)' }, {
{ value: 'Loading', label: 'Loading (per unit × rate)' }, value: "",
{ value: 'Unloading', label: 'Unloading (per unit × rate)' }, label: formData.subDepartmentId
{ value: 'Standard', label: 'Standard Work (flat rate)' }, ? "Select Activity (Optional)"
{ value: 'Other', label: 'Other (flat rate)' }, : "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={formData.activity === 'Loading' || formData.activity === 'Unloading' label={(() => {
? "Rate per Unit (₹)" const selectedActivity = activities.find((a) =>
: "Standard Rate (₹)"} a.name === formData.activity
);
return selectedActivity?.unit_of_measurement === "Per Bag"
? "Rate per Unit (₹)"
: "Rate Amount (₹)";
})()}
name="rate" name="rate"
type="number" type="number"
value={formData.rate} value={formData.rate}
@@ -389,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="primary"
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>

586
src/pages/ReportingPage.tsx Normal file
View File

@@ -0,0 +1,586 @@
import React, { useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
ArrowRight,
Building2,
Calendar,
Check,
CheckCircle2,
Download,
FileSpreadsheet,
RefreshCw,
Search,
User,
Users,
} 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 { api } from "../services/api.ts";
import { useDepartments } from "../hooks/useDepartments.ts";
import { useAuth } from "../contexts/authContext.ts";
import { exportReportToXLSX } from "../utils/excelExport.ts";
type WizardStep = 1 | 2 | 3 | 4;
interface SelectionCard {
id: string | number;
name: string;
subtitle?: string;
}
export const ReportingPage: React.FC = () => {
const { user } = useAuth();
const { departments } = useDepartments();
// Wizard state
const [wizardStep, setWizardStep] = useState<WizardStep>(1);
const [showResults, setShowResults] = useState(false);
// Selections
const [dateRange, setDateRange] = useState({ startDate: "", endDate: "" });
const [selectedDepartment, setSelectedDepartment] = useState<string | null>(null);
const [selectedContractor, setSelectedContractor] = useState<string | null>(null);
const [selectedEmployee, setSelectedEmployee] = useState<string | null>(null);
// Data
const [allocations, setAllocations] = useState<any[]>([]);
const [summary, setSummary] = useState<{
totalAllocations: number;
totalAmount: string;
totalUnits: string;
} | null>(null);
const [contractors, setContractors] = useState<any[]>([]);
const [employees, setEmployees] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const isSupervisor = user?.role === "Supervisor";
const isContractor = user?.role === "Contractor";
// Fetch contractors and employees
useEffect(() => {
api.getUsers({ role: "Contractor" }).then(setContractors).catch(console.error);
api.getUsers({ role: "Employee" }).then(setEmployees).catch(console.error);
}, []);
// Filter contractors by department
const filteredContractors = useMemo(() => {
if (!selectedDepartment || selectedDepartment === "all") return contractors;
return contractors.filter((c) => c.department_id === parseInt(selectedDepartment));
}, [contractors, selectedDepartment]);
// Filter employees by contractor
const filteredEmployees = useMemo(() => {
if (!selectedContractor || selectedContractor === "all") return employees;
return employees.filter((e) => e.contractor_id === parseInt(selectedContractor));
}, [employees, selectedContractor]);
// Generate report
const generateReport = async () => {
setLoading(true);
setError("");
try {
const params: Record<string, any> = {};
if (dateRange.startDate) params.startDate = dateRange.startDate;
if (dateRange.endDate) params.endDate = dateRange.endDate;
// Use user's department if Supervisor
if (isSupervisor && user?.department_id) {
params.departmentId = user.department_id;
} else if (selectedDepartment && selectedDepartment !== "all") {
params.departmentId = parseInt(selectedDepartment);
}
// Use user's id if Contractor
if (isContractor && user?.id) {
params.contractorId = user.id;
} else if (selectedContractor && selectedContractor !== "all") {
params.contractorId = parseInt(selectedContractor);
}
if (selectedEmployee && selectedEmployee !== "all") {
params.employeeId = parseInt(selectedEmployee);
}
const data = await api.getCompletedAllocationsReport(params);
setAllocations(data.allocations);
setSummary(data.summary);
setShowResults(true);
} catch (err: any) {
setError(err.message || "Failed to fetch report");
} finally {
setLoading(false);
}
};
// Filter allocations by search
const filteredAllocations = useMemo(() => {
if (!searchQuery) return allocations;
const query = searchQuery.toLowerCase();
return allocations.filter((a) =>
a.employee_name?.toLowerCase().includes(query) ||
a.contractor_name?.toLowerCase().includes(query) ||
a.sub_department_name?.toLowerCase().includes(query) ||
a.activity?.toLowerCase().includes(query) ||
a.department_name?.toLowerCase().includes(query)
);
}, [allocations, searchQuery]);
// Get selection names for summary
const getSelectionSummary = () => {
const deptName = selectedDepartment === "all"
? "All Departments"
: departments.find((d) => d.id === parseInt(selectedDepartment || ""))?.name || "All";
const contractorName = selectedContractor === "all"
? "All Contractors"
: contractors.find((c) => c.id === parseInt(selectedContractor || ""))?.name || "All";
const employeeName = selectedEmployee === "all"
? "All Employees"
: employees.find((e) => e.id === parseInt(selectedEmployee || ""))?.name || "All";
return { deptName, contractorName, employeeName };
};
// Export to Excel
const handleExport = () => {
if (filteredAllocations.length === 0) {
alert("No data to export");
return;
}
const { deptName } = getSelectionSummary();
exportReportToXLSX(filteredAllocations, deptName, dateRange);
};
// Reset wizard
const resetWizard = () => {
setWizardStep(1);
setShowResults(false);
setDateRange({ startDate: "", endDate: "" });
setSelectedDepartment(null);
setSelectedContractor(null);
setSelectedEmployee(null);
setAllocations([]);
setSummary(null);
setSearchQuery("");
};
// Selection card component
const SelectionCard = ({
item,
isSelected,
onClick,
icon,
}: {
item: SelectionCard;
isSelected: boolean;
onClick: () => void;
icon: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
className={`p-4 rounded-lg border-2 transition-all text-left w-full ${
isSelected
? "border-blue-500 bg-blue-50 ring-2 ring-blue-200"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isSelected ? "bg-blue-100" : "bg-gray-100"}`}>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className={`font-medium truncate ${isSelected ? "text-blue-700" : "text-gray-800"}`}>
{item.name}
</div>
{item.subtitle && (
<div className="text-sm text-gray-500 truncate">{item.subtitle}</div>
)}
</div>
{isSelected && <CheckCircle2 className="text-blue-500 flex-shrink-0" size={20} />}
</div>
</button>
);
// Step indicator
const StepIndicator = () => (
<div className="flex items-center justify-center gap-2 mb-8">
{[1, 2, 3, 4].map((step) => (
<React.Fragment key={step}>
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
step < wizardStep
? "bg-green-500 text-white"
: step === wizardStep
? "bg-blue-600 text-white ring-4 ring-blue-200"
: "bg-gray-200 text-gray-500"
}`}
>
{step < wizardStep ? <Check size={18} /> : step}
</div>
{step < 4 && (
<div className={`w-12 h-1 rounded ${step < wizardStep ? "bg-green-500" : "bg-gray-200"}`} />
)}
</React.Fragment>
))}
</div>
);
// Render wizard steps
const renderWizardContent = () => {
switch (wizardStep) {
case 1:
return (
<div className="space-y-6">
<div className="text-center">
<Calendar className="mx-auto text-blue-600 mb-3" size={48} />
<h3 className="text-xl font-semibold text-gray-800">Select Date Range</h3>
<p className="text-gray-500 mt-1">Choose the period for your report</p>
</div>
<div className="max-w-md mx-auto space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Start Date</label>
<input
type="date"
value={dateRange.startDate}
onChange={(e) => setDateRange((prev) => ({ ...prev, startDate: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">End Date</label>
<input
type="date"
value={dateRange.endDate}
onChange={(e) => setDateRange((prev) => ({ ...prev, endDate: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
type="button"
onClick={() => {
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
setDateRange({
startDate: firstDay.toISOString().split("T")[0],
endDate: today.toISOString().split("T")[0],
});
}}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Use Current Month
</button>
</div>
</div>
);
case 2:
return (
<div className="space-y-6">
<div className="text-center">
<Building2 className="mx-auto text-blue-600 mb-3" size={48} />
<h3 className="text-xl font-semibold text-gray-800">Select Department</h3>
<p className="text-gray-500 mt-1">Choose a department or select all</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 max-w-3xl mx-auto">
<SelectionCard
item={{ id: "all", name: "All Departments", subtitle: `${departments.length} departments` }}
isSelected={selectedDepartment === "all"}
onClick={() => setSelectedDepartment("all")}
icon={<Building2 size={20} className={selectedDepartment === "all" ? "text-blue-600" : "text-gray-500"} />}
/>
{departments.map((dept) => (
<SelectionCard
key={dept.id}
item={{ id: dept.id, name: dept.name }}
isSelected={selectedDepartment === String(dept.id)}
onClick={() => setSelectedDepartment(String(dept.id))}
icon={<Building2 size={20} className={selectedDepartment === String(dept.id) ? "text-blue-600" : "text-gray-500"} />}
/>
))}
</div>
</div>
);
case 3:
return (
<div className="space-y-6">
<div className="text-center">
<Users className="mx-auto text-blue-600 mb-3" size={48} />
<h3 className="text-xl font-semibold text-gray-800">Select Contractor</h3>
<p className="text-gray-500 mt-1">Choose a contractor or select all</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 max-w-3xl mx-auto max-h-96 overflow-y-auto">
<SelectionCard
item={{ id: "all", name: "All Contractors", subtitle: `${filteredContractors.length} contractors` }}
isSelected={selectedContractor === "all"}
onClick={() => setSelectedContractor("all")}
icon={<Users size={20} className={selectedContractor === "all" ? "text-blue-600" : "text-gray-500"} />}
/>
{filteredContractors.map((contractor) => (
<SelectionCard
key={contractor.id}
item={{ id: contractor.id, name: contractor.name, subtitle: contractor.department_name }}
isSelected={selectedContractor === String(contractor.id)}
onClick={() => setSelectedContractor(String(contractor.id))}
icon={<Users size={20} className={selectedContractor === String(contractor.id) ? "text-blue-600" : "text-gray-500"} />}
/>
))}
</div>
</div>
);
case 4:
return (
<div className="space-y-6">
<div className="text-center">
<User className="mx-auto text-blue-600 mb-3" size={48} />
<h3 className="text-xl font-semibold text-gray-800">Select Employee</h3>
<p className="text-gray-500 mt-1">Choose an employee or select all</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 max-w-3xl mx-auto max-h-96 overflow-y-auto">
<SelectionCard
item={{ id: "all", name: "All Employees", subtitle: `${filteredEmployees.length} employees` }}
isSelected={selectedEmployee === "all"}
onClick={() => setSelectedEmployee("all")}
icon={<User size={20} className={selectedEmployee === "all" ? "text-blue-600" : "text-gray-500"} />}
/>
{filteredEmployees.map((employee) => (
<SelectionCard
key={employee.id}
item={{ id: employee.id, name: employee.name, subtitle: employee.contractor_name }}
isSelected={selectedEmployee === String(employee.id)}
onClick={() => setSelectedEmployee(String(employee.id))}
icon={<User size={20} className={selectedEmployee === String(employee.id) ? "text-blue-600" : "text-gray-500"} />}
/>
))}
</div>
</div>
);
}
};
// Check if can proceed to next step
const canProceed = () => {
switch (wizardStep) {
case 1: return true; // Date range is optional
case 2: return selectedDepartment !== null;
case 3: return selectedContractor !== null;
case 4: return selectedEmployee !== null;
default: return false;
}
};
// Results view
if (showResults) {
const { deptName, contractorName, employeeName } = getSelectionSummary();
return (
<div className="p-6">
<Card>
<div className="border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileSpreadsheet className="text-green-600" size={24} />
<div>
<h2 className="text-xl font-semibold text-gray-800">Work Allocation Report</h2>
<p className="text-sm text-gray-500">
{dateRange.startDate && dateRange.endDate
? `${new Date(dateRange.startDate).toLocaleDateString()} - ${new Date(dateRange.endDate).toLocaleDateString()}`
: "All Time"} {deptName} {contractorName} {employeeName}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={resetWizard}>
<ArrowLeft size={16} className="mr-2" />
New Report
</Button>
<Button onClick={handleExport} disabled={filteredAllocations.length === 0}>
<Download size={16} className="mr-2" />
Export to Excel
</Button>
</div>
</div>
</div>
<CardContent>
{/* Summary Cards */}
{summary && (
<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="text-sm text-blue-600 font-medium">Total Completed</div>
<div className="text-2xl font-bold text-blue-800">{summary.totalAllocations}</div>
</div>
<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-2xl font-bold text-green-800">
{parseFloat(summary.totalAmount).toLocaleString()}
</div>
</div>
<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-2xl font-bold text-purple-800">
{parseFloat(summary.totalUnits).toLocaleString()}
</div>
</div>
</div>
)}
{/* Search */}
<div className="flex gap-4 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder="Search results..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<Button variant="ghost" onClick={generateReport}>
<RefreshCw size={16} className="mr-2" />
Refresh
</Button>
</div>
{error && (
<div className="text-center py-4 text-red-600 bg-red-50 rounded-md mb-4">Error: {error}</div>
)}
{/* Table */}
{loading ? (
<div className="text-center py-8">Loading report data...</div>
) : filteredAllocations.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableHead>ID</TableHead>
<TableHead>Employee</TableHead>
<TableHead>Contractor</TableHead>
<TableHead>Department</TableHead>
<TableHead>Sub-Department</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Assigned</TableHead>
<TableHead>Completed</TableHead>
<TableHead>Actual Rate ()</TableHead>
<TableHead>Standard Rate ()</TableHead>
<TableHead>Difference ()</TableHead>
<TableHead>Units</TableHead>
<TableHead>Total ()</TableHead>
</TableHeader>
<TableBody>
{filteredAllocations.map((allocation) => {
const rate = parseFloat(allocation.rate) || 0;
const standardRate = parseFloat(allocation.standard_rate) || 0;
const difference = rate - standardRate;
const units = parseFloat(allocation.units) || 0;
const total = parseFloat(allocation.total_amount) || rate;
return (
<TableRow key={allocation.id}>
<TableCell>{allocation.id}</TableCell>
<TableCell className="font-medium">{allocation.employee_name || "-"}</TableCell>
<TableCell>{allocation.contractor_name || "-"}</TableCell>
<TableCell>{allocation.department_name || "-"}</TableCell>
<TableCell>{allocation.sub_department_name || "-"}</TableCell>
<TableCell>
<span className={`px-2 py-1 rounded text-xs font-medium ${
allocation.activity === "Loading" || allocation.activity === "Unloading"
? "bg-purple-100 text-purple-700"
: "bg-gray-100 text-gray-700"
}`}>
{allocation.activity || "Standard"}
</span>
</TableCell>
<TableCell>{new Date(allocation.assigned_date).toLocaleDateString()}</TableCell>
<TableCell>
{allocation.completion_date ? new Date(allocation.completion_date).toLocaleDateString() : "-"}
</TableCell>
<TableCell>{rate.toFixed(2)}</TableCell>
<TableCell>{standardRate > 0 ? `${standardRate.toFixed(2)}` : "-"}</TableCell>
<TableCell>
{standardRate > 0 ? (
<span className={`font-medium ${
difference > 0 ? "text-red-600" : difference < 0 ? "text-green-600" : "text-gray-600"
}`}>
{difference > 0 ? "+" : ""}{difference.toFixed(2)}
</span>
) : "-"}
</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 for the selected criteria.
</div>
)}
</CardContent>
</Card>
</div>
);
}
// Wizard view
return (
<div className="p-6">
<Card>
<div className="border-b border-gray-200 px-6 py-4">
<div className="flex items-center gap-3">
<FileSpreadsheet className="text-green-600" size={24} />
<h2 className="text-xl font-semibold text-gray-800">Generate Work Allocation Report</h2>
</div>
</div>
<CardContent>
<div className="py-8">
<StepIndicator />
{renderWizardContent()}
{/* Navigation */}
<div className="flex justify-between mt-8 max-w-3xl mx-auto">
<Button
variant="secondary"
onClick={() => setWizardStep((prev) => Math.max(1, prev - 1) as WizardStep)}
disabled={wizardStep === 1}
>
<ArrowLeft size={16} className="mr-2" />
Back
</Button>
{wizardStep < 4 ? (
<Button onClick={() => setWizardStep((prev) => (prev + 1) as WizardStep)} disabled={!canProceed()}>
Next
<ArrowRight size={16} className="ml-2" />
</Button>
) : (
<Button onClick={generateReport} disabled={!canProceed() || loading}>
{loading ? "Generating..." : "Generate Report"}
<FileSpreadsheet size={16} className="ml-2" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,644 @@
import React, { useEffect, useMemo, useState } from "react";
import {
ArrowUpDown,
DollarSign,
Edit,
RefreshCw,
Scale,
Search,
Trash2,
} 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.ts";
export const StandardRatesPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<"list" | "add" | "compare">(
"list",
);
const { user } = useAuth();
const { departments } = useDepartments();
const [standardRates, setStandardRates] = useState<any[]>([]);
const [contractors, setContractors] = useState<any[]>([]);
const [comparisons, setComparisons] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
// Form state
const [formData, setFormData] = useState({
subDepartmentId: "",
activity: "",
rate: "",
effectiveDate: new Date().toISOString().split("T")[0],
});
const [selectedDept, setSelectedDept] = useState("");
const { subDepartments } = useSubDepartments(selectedDept);
const { activities } = useActivities(formData.subDepartmentId);
const [formError, setFormError] = useState("");
const [formLoading, setFormLoading] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
// Compare filters
const [compareContractorId, setCompareContractorId] = useState("");
const isSupervisor = user?.role === "Supervisor";
const canManageRates = user?.role === "SuperAdmin" ||
user?.role === "Supervisor";
// Fetch standard rates
const fetchStandardRates = async () => {
setLoading(true);
setError("");
try {
const data = await api.getStandardRates();
setStandardRates(data);
} catch (err: any) {
setError(err.message || "Failed to fetch standard rates");
} finally {
setLoading(false);
}
};
// Fetch contractors
const fetchContractors = async () => {
try {
const data = await api.getUsers({ role: "Contractor" });
setContractors(data);
} catch (err) {
console.error("Failed to fetch contractors:", err);
}
};
// Fetch comparison data
const fetchComparison = async () => {
if (!compareContractorId) {
setComparisons([]);
return;
}
setLoading(true);
try {
const data = await api.compareRates({
contractorId: parseInt(compareContractorId),
});
setComparisons(data.comparisons);
} catch (err: any) {
setError(err.message || "Failed to fetch comparison");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStandardRates();
fetchContractors();
}, []);
useEffect(() => {
if (isSupervisor && user?.department_id) {
setSelectedDept(String(user.department_id));
}
}, [isSupervisor, user?.department_id]);
useEffect(() => {
if (activeTab === "compare" && compareContractorId) {
fetchComparison();
}
}, [activeTab, compareContractorId]);
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
// Clear activity when sub-department changes
if (name === "subDepartmentId") {
setFormData((prev) => ({ ...prev, [name]: value, activity: "" }));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
setFormError("");
};
const resetForm = () => {
setFormData({
subDepartmentId: "",
activity: "",
rate: "",
effectiveDate: new Date().toISOString().split("T")[0],
});
setEditingId(null);
setFormError("");
};
const handleSubmit = async () => {
if (!formData.rate || !formData.effectiveDate) {
setFormError("Rate and effective date are required");
return;
}
setFormLoading(true);
setFormError("");
try {
if (editingId) {
await api.updateStandardRate(editingId, {
rate: parseFloat(formData.rate),
activity: formData.activity || undefined,
effectiveDate: formData.effectiveDate,
});
} else {
await api.createStandardRate({
subDepartmentId: formData.subDepartmentId
? parseInt(formData.subDepartmentId)
: undefined,
activity: formData.activity || undefined,
rate: parseFloat(formData.rate),
effectiveDate: formData.effectiveDate,
});
}
resetForm();
setActiveTab("list");
fetchStandardRates();
} catch (err: any) {
setFormError(err.message || "Failed to save rate");
} finally {
setFormLoading(false);
}
};
const handleEdit = (rate: any) => {
setFormData({
subDepartmentId: rate.sub_department_id
? String(rate.sub_department_id)
: "",
activity: rate.activity || "",
rate: String(rate.rate),
effectiveDate: rate.effective_date?.split("T")[0] ||
new Date().toISOString().split("T")[0],
});
if (rate.department_id) {
setSelectedDept(String(rate.department_id));
}
setEditingId(rate.id);
setActiveTab("add");
};
const handleDelete = async (id: number) => {
if (!confirm("Are you sure you want to delete this standard rate?")) return;
try {
await api.deleteStandardRate(id);
fetchStandardRates();
} catch (err: any) {
alert(err.message || "Failed to delete rate");
}
};
// Filter rates based on search
const filteredRates = useMemo(() => {
if (!searchQuery) return standardRates;
const query = searchQuery.toLowerCase();
return standardRates.filter((rate) =>
rate.sub_department_name?.toLowerCase().includes(query) ||
rate.department_name?.toLowerCase().includes(query) ||
rate.activity?.toLowerCase().includes(query) ||
rate.created_by_name?.toLowerCase().includes(query)
);
}, [standardRates, searchQuery]);
return (
<div className="p-6">
<Card>
<div className="border-b border-gray-200">
<div className="flex space-x-8 px-6">
<button
onClick={() => {
setActiveTab("list");
resetForm();
}}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === "list"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Standard Rates
</button>
{canManageRates && (
<button
onClick={() => setActiveTab("add")}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === "add"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{editingId ? "Edit Rate" : "Add Standard Rate"}
</button>
)}
<button
onClick={() => setActiveTab("compare")}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === "compare"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Scale size={16} className="inline mr-1" />
Compare Rates
</button>
</div>
</div>
<CardContent>
{activeTab === "list" && (
<div>
<div className="flex gap-4 mb-4">
<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}
/>
<input
type="text"
placeholder="Search by sub-department, activity..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<Button variant="ghost" onClick={fetchStandardRates}>
<RefreshCw size={16} className="mr-2" />
Refresh
</Button>
</div>
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-700">
<strong>Standard Rates</strong>{" "}
are default rates set by supervisors for sub-departments and
activities. These are used as benchmarks to compare against
contractor-specific rates.
</p>
</div>
{error && (
<div className="text-center py-4 text-red-600 bg-red-50 rounded-md mb-4">
Error: {error}
</div>
)}
{loading
? (
<div className="text-center py-8">
Loading standard rates...
</div>
)
: filteredRates.length > 0
? (
<Table>
<TableHeader>
<TableHead>Department</TableHead>
<TableHead>Sub-Department</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Rate ()</TableHead>
<TableHead>Effective Date</TableHead>
<TableHead>Created By</TableHead>
{canManageRates && <TableHead>Actions</TableHead>}
</TableHeader>
<TableBody>
{filteredRates.map((rate) => (
<TableRow key={rate.id}>
<TableCell>{rate.department_name || "-"}</TableCell>
<TableCell className="font-medium">
{rate.sub_department_name || "All"}
</TableCell>
<TableCell>
<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-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>
)}
{activeTab === "add" && canManageRates && (
<div className="max-w-2xl space-y-6">
<h3 className="text-lg font-semibold text-gray-800">
{editingId ? "Edit Standard Rate" : "Add New Standard Rate"}
</h3>
<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>
<p className="text-sm text-yellow-700">
Standard rates serve as default benchmarks for sub-departments
and activities. Contractor rates can be compared against these
to identify deviations.
</p>
</div>
{formError && (
<div className="p-3 bg-red-100 text-red-700 rounded-md">
{formError}
</div>
)}
<div className="grid grid-cols-2 gap-6">
{isSupervisor
? (
<Input
label="Department"
value={departments.find((d) =>
d.id === user?.department_id
)?.name || "Loading..."}
disabled
/>
)
: (
<Select
label="Department"
value={selectedDept}
onChange={(e) => setSelectedDept(e.target.value)}
options={[
{ value: "", label: "Select Department" },
...departments.map((d) => ({
value: String(d.id),
label: d.name,
})),
]}
/>
)}
<Select
label="Sub-Department"
name="subDepartmentId"
value={formData.subDepartmentId}
onChange={handleInputChange}
disabled={!!editingId}
options={[
{ value: "", label: "All Sub-Departments" },
...subDepartments.map((s) => ({
value: String(s.id),
label: s.name,
})),
]}
/>
<Select
label="Activity Type"
name="activity"
value={formData.activity}
onChange={handleInputChange}
disabled={!formData.subDepartmentId}
options={[
{
value: "",
label: formData.subDepartmentId
? "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
label={(() => {
const selectedActivity = activities.find((a) =>
a.name === formData.activity
);
return selectedActivity?.unit_of_measurement === "Per Bag"
? "Rate per Unit (₹)"
: "Standard Rate (₹)";
})()}
name="rate"
type="number"
value={formData.rate}
onChange={handleInputChange}
placeholder="Enter rate amount"
required
/>
<Input
label="Effective Date"
name="effectiveDate"
type="date"
value={formData.effectiveDate}
onChange={handleInputChange}
required
/>
</div>
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={() => {
setActiveTab("list");
resetForm();
}}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={formLoading}>
{formLoading ? "Saving..." : (
<>
<DollarSign size={16} className="mr-2" />
{editingId ? "Update Rate" : "Add Standard Rate"}
</>
)}
</Button>
</div>
</div>
)}
{activeTab === "compare" && (
<div>
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
<ArrowUpDown size={20} className="inline mr-2" />
Compare Contractor Rates vs Standard Rates
</h3>
<div className="flex gap-4 items-end">
<div className="w-64">
<Select
label="Select Contractor"
value={compareContractorId}
onChange={(e) => setCompareContractorId(e.target.value)}
options={[
{ value: "", label: "Select Contractor" },
...contractors.map((c) => ({
value: String(c.id),
label: c.name,
})),
]}
/>
</div>
<Button
onClick={fetchComparison}
disabled={!compareContractorId}
>
Compare
</Button>
</div>
</div>
{loading
? <div className="text-center py-8">Loading comparison...</div>
: comparisons.length > 0
? (
<Table>
<TableHeader>
<TableHead>Sub-Department</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Contractor Rate ()</TableHead>
<TableHead>Standard Rate ()</TableHead>
<TableHead>Difference ()</TableHead>
<TableHead>Status</TableHead>
</TableHeader>
<TableBody>
{comparisons.map((comp, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">
{comp.sub_department_name || "All"}
</TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded text-xs font-medium ${
comp.unit_of_measurement === "Per Bag"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-700"
}`}
>
{comp.activity || "Standard"}
</span>
</TableCell>
<TableCell className="font-semibold">
{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>
</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>
)
: comp.is_below_standard
? (
<span className="px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">
Below Standard ({comp.percentage_difference}%)
</span>
)
: (
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700">
At Standard
</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>
)}
</CardContent>
</Card>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +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.tsx";
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 { useAuth } from '../contexts/AuthContext'; TableRow,
import { api } from '../services/api'; } from "../components/ui/Table.tsx";
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.ts";
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 [formError, setFormError] = useState(''); const { activities } = useActivities(formData.subDepartmentId);
const [formError, setFormError] = useState("");
const [formLoading, setFormLoading] = useState(false); const [formLoading, setFormLoading] = useState(false);
const [contractorRates, setContractorRates] = useState<any[]>([]); const [contractorRates, setContractorRates] = useState<any[]>([]);
@@ -56,11 +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)
// Check if rate is per unit (Loading/Unloading) );
const isPerUnitRate = selectedRate?.activity === 'Loading' || selectedRate?.activity === 'Unloading';
// Get selected activity details
const selectedActivity = activities.find((a) => a.name === formData.activity);
// Check if rate is per unit based on activity's unit_of_measurement
const isPerUnitRate = selectedActivity?.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;
@@ -71,40 +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;
setFormData(prev => ({ ...prev, [name]: value }));
setFormError(''); // Auto-select department when contractor is selected
if (name === "contractorId" && value) {
const selectedContractor = contractors.find((c) =>
String(c.id) === value
);
if (selectedContractor?.department_id) {
setSelectedDept(String(selectedContractor.department_id));
// Clear sub-department and activity when contractor changes
setFormData((prev) => ({
...prev,
[name]: value,
subDepartmentId: "",
activity: "",
rateId: "",
}));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
} // Clear activity when sub-department changes
else if (name === "subDepartmentId") {
setFormData((prev) => ({ ...prev, [name]: value, activity: "" }));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
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,
@@ -115,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);
} }
@@ -135,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 (
@@ -163,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 type="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}
@@ -200,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
@@ -211,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
@@ -247,12 +326,22 @@ export const WorkAllocationPage: React.FC = () => {
name="activity" name="activity"
value={formData.activity} value={formData.activity}
onChange={handleInputChange} onChange={handleInputChange}
disabled={!formData.subDepartmentId}
options={[ options={[
{ value: '', label: 'Select Activity' }, {
{ value: 'Loading', label: 'Loading' }, value: "",
{ value: 'Unloading', label: 'Unloading' }, label: formData.subDepartmentId
{ value: 'Standard', label: 'Standard Work' }, ? "Select Activity"
{ value: 'Other', label: 'Other' }, : "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
@@ -270,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 && (
@@ -298,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>
@@ -336,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="primary" onClick={() => setActiveTab("view")}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreateAllocation} disabled={formLoading}> <Button variant="primary" onClick={handleCreateAllocation} disabled={formLoading}>
{formLoading ? 'Creating...' : ( {formLoading ? "Creating..." : (
<> <>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Create Allocation Create Allocation
@@ -351,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..."
@@ -375,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 (
@@ -388,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,178 +32,413 @@ 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 }),
}); });
} }
// Work Allocations // Sub-Departments
async getWorkAllocations(params?: { employeeId?: number; status?: string; departmentId?: number }) { createSubDepartment(data: { department_id: number; name: string }) {
const query = new URLSearchParams(params as any).toString(); return this.request<any>("/departments/sub-departments", {
return this.request<any[]>(`/work-allocations${query ? `?${query}` : ''}`); method: "POST",
}
async getWorkAllocation(id: number) {
return this.request<any>(`/work-allocations/${id}`);
}
async createWorkAllocation(data: any) {
return this.request<any>('/work-allocations', {
method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
async updateWorkAllocationStatus(id: number, status: string, completionDate?: string) { deleteSubDepartment(id: number) {
return this.request<{ message: string }>(
`/departments/sub-departments/${id}`,
{
method: "DELETE",
},
);
}
// Work Allocations
getWorkAllocations(
params?: { employeeId?: number; status?: string; departmentId?: number },
) {
const query = new URLSearchParams(params as any).toString();
return this.request<any[]>(`/work-allocations${query ? `?${query}` : ""}`);
}
getWorkAllocation(id: number) {
return this.request<any>(`/work-allocations/${id}`);
}
createWorkAllocation(data: any) {
return this.request<any>("/work-allocations", {
method: "POST",
body: JSON.stringify(data),
});
}
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}` : ""}`,
);
}
updateAttendanceStatus(id: number, status: string, remark?: string) {
return this.request<any>(`/attendance/${id}/status`, {
method: "PUT",
body: JSON.stringify({ status, remark }),
});
}
markAbsent(employeeId: number, workDate: string, remark?: string) {
return this.request<any>("/attendance/mark-absent", {
method: "POST",
body: JSON.stringify({ employeeId, workDate, remark }),
});
}
// Employee Swaps
getEmployeeSwaps(
params?: {
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}` : ""}`);
}
getEmployeeSwap(id: number) {
return this.request<any>(`/employee-swaps/${id}`);
}
createEmployeeSwap(data: {
employeeId: number;
targetDepartmentId: number;
targetContractorId?: number;
swapReason: string;
reasonDetails?: string;
workCompletionPercentage?: number;
swapDate: string;
}) {
return this.request<any>("/employee-swaps", {
method: "POST",
body: JSON.stringify(data),
});
}
completeEmployeeSwap(id: number) {
return this.request<any>(`/employee-swaps/${id}/complete`, {
method: "PUT",
});
}
cancelEmployeeSwap(id: number) {
return this.request<any>(`/employee-swaps/${id}/cancel`, {
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
getCompletedAllocationsReport(params?: {
startDate?: string;
endDate?: string;
departmentId?: number;
contractorId?: number;
employeeId?: number;
}) {
const query = params ? new URLSearchParams(params as any).toString() : "";
return this.request<{
allocations: any[];
summary: {
totalAllocations: number;
totalAmount: string;
totalUnits: string;
};
}>(`/reports/completed-allocations${query ? `?${query}` : ""}`);
}
getReportSummary(params?: { startDate?: string; endDate?: string }) {
const query = params ? new URLSearchParams(params as any).toString() : "";
return this.request<{
byContractor: any[];
bySubDepartment: any[];
byActivity: any[];
}>(`/reports/summary${query ? `?${query}` : ""}`);
}
// Standard Rates
getStandardRates(
params?: {
departmentId?: number;
subDepartmentId?: number;
activity?: string;
},
) {
const query = params ? new URLSearchParams(params as any).toString() : "";
return this.request<any[]>(`/standard-rates${query ? `?${query}` : ""}`);
}
getAllRates(
params?: { departmentId?: number; startDate?: string; endDate?: string },
) {
const query = params ? new URLSearchParams(params as any).toString() : "";
return this.request<{
allRates: any[];
summary: {
totalContractorRates: number;
totalStandardRates: number;
totalRates: number;
};
}>(`/standard-rates/all-rates${query ? `?${query}` : ""}`);
}
compareRates(params?: { contractorId?: number; subDepartmentId?: number }) {
const query = params ? new URLSearchParams(params as any).toString() : "";
return this.request<{
standardRates: any[];
contractorRates: any[];
comparisons: any[];
}>(`/standard-rates/compare${query ? `?${query}` : ""}`);
}
createStandardRate(data: {
subDepartmentId?: number;
activity?: string;
rate: number;
effectiveDate: string;
}) {
return this.request<any>("/standard-rates", {
method: "POST",
body: JSON.stringify(data),
});
}
updateStandardRate(
id: number,
data: { rate?: number; activity?: string; effectiveDate?: string },
) {
return this.request<any>(`/standard-rates/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
deleteStandardRate(id: number) {
return this.request<{ message: string }>(`/standard-rates/${id}`, {
method: "DELETE",
});
}
// Activities
getActivities(params?: { departmentId?: number; subDepartmentId?: number }) {
const query = params ? new URLSearchParams(params as any).toString() : "";
return this.request<any[]>(`/activities${query ? `?${query}` : ""}`);
}
getActivity(id: number) {
return this.request<any>(`/activities/${id}`);
}
createActivity(
data: {
sub_department_id: number;
name: string;
unit_of_measurement?: string;
},
) {
return this.request<any>("/activities", {
method: "POST",
body: JSON.stringify(data),
});
}
updateActivity(
id: number,
data: { name?: string; unit_of_measurement?: string },
) {
return this.request<any>(`/activities/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
deleteActivity(id: number) {
return this.request<{ message: string }>(`/activities/${id}`, {
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,13 +3,24 @@ 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;
created_at: string; created_at: string;
department_name?: string; department_name?: string;
contractor_name?: string; contractor_name?: string;
sub_department_id?: number;
sub_department_name?: string;
primary_activity?: string;
phone_number?: string;
aadhar_number?: string;
bank_account_number?: string;
bank_name?: string;
bank_ifsc?: string;
contractor_agreement_number?: string;
pf_number?: string;
esic_number?: string;
} }
export interface Department { export interface Department {
@@ -23,9 +34,20 @@ export interface SubDepartment {
id: number; id: number;
department_id: number; department_id: number;
name: string; name: string;
primary_activity: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
department_name?: string;
}
export interface Activity {
id: number;
sub_department_id: number;
name: string;
unit_of_measurement: "Per Bag" | "Fixed Rate-Per Person";
created_at: string;
sub_department_name?: string;
department_id?: number;
department_name?: string;
} }
export interface WorkAllocation { export interface WorkAllocation {
@@ -36,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;
@@ -49,6 +71,13 @@ export interface WorkAllocation {
department_name?: string; department_name?: string;
} }
export type AttendanceStatus =
| "CheckedIn"
| "CheckedOut"
| "Absent"
| "HalfDay"
| "Late";
export interface Attendance { export interface Attendance {
id: number; id: number;
employee_id: number; employee_id: number;
@@ -56,7 +85,8 @@ export interface Attendance {
check_in_time: string; check_in_time: string;
check_out_time?: string; check_out_time?: string;
work_date: string; work_date: string;
status: 'CheckedIn' | 'CheckedOut'; status: AttendanceStatus;
remark?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
employee_name?: string; employee_name?: string;
@@ -66,13 +96,73 @@ export interface Attendance {
contractor_name?: string; contractor_name?: string;
} }
export type SwapReason = "LeftWork" | "Sick" | "FinishedEarly" | "Other";
export type SwapStatus = "Active" | "Completed" | "Cancelled";
export interface EmployeeSwap {
id: number;
employee_id: number;
original_department_id: number;
target_department_id: number;
original_contractor_id?: number;
target_contractor_id?: number;
swap_reason: SwapReason;
reason_details?: string;
work_completion_percentage: number;
swap_date: string;
swapped_by: number;
status: SwapStatus;
created_at: string;
completed_at?: string;
// Joined fields
employee_name?: string;
original_department_name?: string;
target_department_name?: string;
original_contractor_name?: string;
target_contractor_name?: string;
swapped_by_name?: string;
}
export interface ContractorRate { export interface ContractorRate {
id: number; id: number;
contractor_id: number; contractor_id: number;
sub_department_id?: number;
activity?: string;
rate: number; rate: number;
effective_date: string; effective_date: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
contractor_name?: string; contractor_name?: string;
contractor_username?: string; contractor_username?: string;
sub_department_name?: string;
department_name?: string;
}
export interface StandardRate {
id: number;
sub_department_id?: number;
activity?: string;
rate: number;
effective_date: string;
created_by: number;
created_at: string;
sub_department_name?: string;
department_name?: string;
department_id?: number;
created_by_name?: string;
}
export interface RateComparison {
id: number;
contractor_id: number;
contractor_name: string;
sub_department_id?: number;
sub_department_name?: string;
activity?: string;
rate: number;
standard_rate: number;
difference: number;
percentage_difference: string | null;
is_above_standard: boolean;
is_below_standard: boolean;
} }

473
src/utils/excelExport.ts Normal file
View File

@@ -0,0 +1,473 @@
import * as XLSX from "xlsx";
interface AllocationData {
id: number;
employee_name: string;
contractor_name: string;
department_name: string;
sub_department_name: string;
activity: string;
assigned_date: string;
completion_date: string;
rate: number;
units: number;
total_amount: number;
status: string;
}
interface WorkReportData {
work: string;
dates: {
[date: string]: {
bag: number;
rate: number;
total: number;
};
};
totalBag: number;
totalAmount: number;
standardBag?: number;
standardRate?: number;
standardTotal?: number;
}
/**
* Export work allocation report to XLSX with proper formatting
* Format matches the provided image with:
* - DATE header row spanning columns for each date
* - Bag, Rate, Total sub-headers for each date
* - Department section headers with yellow background
* - Work activities listed with data
* - Sub Total row at bottom
* - Total-As per Standard columns
*/
export const exportWorkReportToXLSX = (
allocations: AllocationData[],
departmentName: string,
_dateRange: { startDate: string; endDate: string },
) => {
// Group allocations by work (activity + sub_department) and date
const workDataMap = new Map<string, WorkReportData>();
const allDates = new Set<string>();
allocations.forEach((allocation) => {
const workKey = `${allocation.sub_department_name || ""} ${
allocation.activity || "Standard"
}`.trim();
const date = allocation.assigned_date
? new Date(allocation.assigned_date).getDate().toString()
: "";
if (date) {
allDates.add(date);
}
if (!workDataMap.has(workKey)) {
workDataMap.set(workKey, {
work: workKey,
dates: {},
totalBag: 0,
totalAmount: 0,
});
}
const workData = workDataMap.get(workKey)!;
if (!workData.dates[date]) {
workData.dates[date] = { bag: 0, rate: 0, total: 0 };
}
const bag = parseFloat(String(allocation.units)) || 0;
const rate = parseFloat(String(allocation.rate)) || 0;
const total = parseFloat(String(allocation.total_amount)) || (bag * rate) ||
rate;
workData.dates[date].bag += bag;
workData.dates[date].rate = rate; // Use latest rate
workData.dates[date].total += total;
workData.totalBag += bag;
workData.totalAmount += total;
});
// Sort dates numerically
const sortedDates = Array.from(allDates).sort((a, b) =>
parseInt(a) - parseInt(b)
);
// Create workbook and worksheet
const wb = XLSX.utils.book_new();
const wsData: (string | number | null)[][] = [];
// Row 1: DATE header with merged cells for each date
const dateHeaderRow: (string | number | null)[] = ["", "DATE"];
sortedDates.forEach((date) => {
dateHeaderRow.push(date, "", ""); // Each date spans 3 columns (Bag, Rate, Total)
});
dateHeaderRow.push("", "Total", "", "", "Total-As per Standered", "", "");
wsData.push(dateHeaderRow);
// Row 2: WORK and Bag/Rate/Total sub-headers
const subHeaderRow: (string | number | null)[] = ["", "WORK"];
sortedDates.forEach(() => {
subHeaderRow.push("Bag", "Rate", "Total");
});
subHeaderRow.push("", "Bag", "Rate", "Total", "Bag", "Rate", "Total");
wsData.push(subHeaderRow);
// Row 3: Department header (yellow background)
const deptHeaderRow: (string | number | null)[] = [
"",
`${departmentName.toUpperCase()} Department`,
];
const deptHeaderCols = sortedDates.length * 3 + 7;
for (let col = 0; col < deptHeaderCols; col++) {
deptHeaderRow.push("");
}
wsData.push(deptHeaderRow);
// Data rows for each work item
const workDataArray = Array.from(workDataMap.values());
workDataArray.forEach((workData, index) => {
const dataRow: (string | number | null)[] = [index + 1, workData.work];
sortedDates.forEach((date) => {
const dateData = workData.dates[date] || { bag: 0, rate: 0, total: 0 };
dataRow.push(
dateData.bag || "",
dateData.rate || "",
dateData.total || "",
);
});
// Total columns
dataRow.push(""); // Empty column
dataRow.push(workData.totalBag || "");
dataRow.push(""); // Rate for total (could be average)
dataRow.push(workData.totalAmount || "");
// Standard columns (placeholder - would need standard rates data)
dataRow.push("");
dataRow.push("");
dataRow.push("");
wsData.push(dataRow);
});
// Empty row before Sub Total
wsData.push([]);
// Sub Total row
const subTotalRow: (string | number | null)[] = ["", "Sub Total"];
// Calculate totals for each date
sortedDates.forEach((date) => {
let dateBagTotal = 0;
let dateTotalAmount = 0;
workDataArray.forEach((workData) => {
const dateData = workData.dates[date];
if (dateData) {
dateBagTotal += dateData.bag;
dateTotalAmount += dateData.total;
}
});
subTotalRow.push(dateBagTotal || "", "", dateTotalAmount || "");
});
// Grand totals
const grandTotalBag = workDataArray.reduce((sum, w) => sum + w.totalBag, 0);
const grandTotalAmount = workDataArray.reduce(
(sum, w) => sum + w.totalAmount,
0,
);
subTotalRow.push("");
subTotalRow.push(grandTotalBag || "");
subTotalRow.push("");
subTotalRow.push(grandTotalAmount || "");
subTotalRow.push("");
subTotalRow.push("");
subTotalRow.push(grandTotalAmount || ""); // Standard total same as actual for now
wsData.push(subTotalRow);
// Create worksheet
const ws = XLSX.utils.aoa_to_sheet(wsData);
// Set column widths
const colWidths: { wch: number }[] = [
{ wch: 4 }, // A - Row number
{ wch: 35 }, // B - Work name
];
// Add widths for date columns
sortedDates.forEach(() => {
colWidths.push({ wch: 8 }); // Bag
colWidths.push({ wch: 6 }); // Rate
colWidths.push({ wch: 10 }); // Total
});
// Total columns
colWidths.push({ wch: 3 }); // Empty
colWidths.push({ wch: 10 }); // Total Bag
colWidths.push({ wch: 6 }); // Total Rate
colWidths.push({ wch: 12 }); // Total Amount
colWidths.push({ wch: 10 }); // Standard Bag
colWidths.push({ wch: 6 }); // Standard Rate
colWidths.push({ wch: 12 }); // Standard Total
ws["!cols"] = colWidths;
// Merge cells for DATE headers
const merges: XLSX.Range[] = [];
// Merge DATE header cells for each date (row 1)
let colIndex = 2; // Start after row number and WORK columns
sortedDates.forEach(() => {
merges.push({
s: { r: 0, c: colIndex },
e: { r: 0, c: colIndex + 2 },
});
colIndex += 3;
});
// Merge Total header
merges.push({
s: { r: 0, c: colIndex + 1 },
e: { r: 0, c: colIndex + 3 },
});
// Merge "Total-As per Standered" header
merges.push({
s: { r: 0, c: colIndex + 4 },
e: { r: 0, c: colIndex + 6 },
});
// Merge department header row
merges.push({
s: { r: 2, c: 1 },
e: { r: 2, c: colIndex + 6 },
});
ws["!merges"] = merges;
// Add worksheet to workbook
XLSX.utils.book_append_sheet(wb, ws, "Work Report");
// Generate filename
const filename = `work_report_${
departmentName.toLowerCase().replace(/\s+/g, "_")
}_${new Date().toISOString().split("T")[0]}.xlsx`;
// Write and download
XLSX.writeFile(wb, filename);
};
/**
* Simple export for basic allocation data
*/
export const exportAllocationsToXLSX = (
allocations: AllocationData[],
filename?: string,
) => {
if (allocations.length === 0) {
alert("No data to export");
return;
}
// Transform data for export
const exportData = allocations.map((a, index) => ({
"S.No": index + 1,
"Employee Name": a.employee_name || "",
"Contractor": a.contractor_name || "",
"Department": a.department_name || "",
"Sub-Department": a.sub_department_name || "",
"Activity": a.activity || "Standard",
"Assigned Date": a.assigned_date
? new Date(a.assigned_date).toLocaleDateString()
: "",
"Completion Date": a.completion_date
? new Date(a.completion_date).toLocaleDateString()
: "",
"Rate": a.rate || 0,
"Units": a.units || "",
"Total Amount": a.total_amount || a.rate || 0,
"Status": a.status || "",
}));
// Create workbook
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(exportData);
// Set column widths
ws["!cols"] = [
{ wch: 6 }, // S.No
{ wch: 25 }, // Employee Name
{ wch: 20 }, // Contractor
{ wch: 15 }, // Department
{ wch: 20 }, // Sub-Department
{ wch: 15 }, // Activity
{ wch: 12 }, // Assigned Date
{ wch: 14 }, // Completion Date
{ wch: 10 }, // Rate
{ wch: 8 }, // Units
{ wch: 12 }, // Total Amount
{ wch: 10 }, // Status
];
XLSX.utils.book_append_sheet(wb, ws, "Allocations");
// Generate filename
const outputFilename = filename ||
`allocations_${new Date().toISOString().split("T")[0]}.xlsx`;
// Write and download
XLSX.writeFile(wb, outputFilename);
};
/**
* Combined export function with improved formatting
* Includes standard rate and difference columns
*/
export const exportReportToXLSX = (
allocations: AllocationData[],
departmentName: string,
dateRange: { startDate: string; endDate: string },
) => {
if (allocations.length === 0) {
alert("No data to export");
return;
}
// Create workbook
const wb = XLSX.utils.book_new();
// ===== Sheet 1: Detailed Report =====
const detailedData = allocations.map((a, index) => {
const rate = parseFloat(String(a.rate)) || 0;
const standardRate = parseFloat(String((a as any).standard_rate)) || 0;
const difference = standardRate > 0 ? rate - standardRate : 0;
const units = parseFloat(String(a.units)) || 0;
const total = parseFloat(String(a.total_amount)) || rate;
return {
"S.No": index + 1,
"Employee": a.employee_name || "",
"Contractor": a.contractor_name || "",
"Department": a.department_name || "",
"Sub-Department": a.sub_department_name || "",
"Activity": a.activity || "Standard",
"Assigned Date": a.assigned_date
? new Date(a.assigned_date).toLocaleDateString()
: "",
"Completed Date": a.completion_date
? new Date(a.completion_date).toLocaleDateString()
: "",
"Actual Rate (₹)": rate,
"Standard Rate (₹)": standardRate > 0 ? standardRate : "-",
"Difference (₹)": standardRate > 0 ? difference : "-",
"Units": units > 0 ? units : "-",
"Total Amount (₹)": total,
};
});
const ws1 = XLSX.utils.json_to_sheet(detailedData);
// Set column widths for detailed sheet
ws1["!cols"] = [
{ wch: 6 }, // S.No
{ wch: 22 }, // Employee
{ wch: 20 }, // Contractor
{ wch: 15 }, // Department
{ wch: 18 }, // Sub-Department
{ wch: 15 }, // Activity
{ wch: 12 }, // Assigned Date
{ wch: 14 }, // Completed Date
{ wch: 14 }, // Actual Rate
{ wch: 16 }, // Standard Rate
{ wch: 14 }, // Difference
{ wch: 8 }, // Units
{ wch: 16 }, // Total Amount
];
XLSX.utils.book_append_sheet(wb, ws1, "Detailed Report");
// ===== Sheet 2: Summary by Activity =====
const activitySummary = new Map<string, { count: number; totalAmount: number; totalUnits: number }>();
allocations.forEach((a) => {
const activity = a.activity || "Standard";
const existing = activitySummary.get(activity) || { count: 0, totalAmount: 0, totalUnits: 0 };
existing.count += 1;
existing.totalAmount += parseFloat(String(a.total_amount)) || parseFloat(String(a.rate)) || 0;
existing.totalUnits += parseFloat(String(a.units)) || 0;
activitySummary.set(activity, existing);
});
const summaryData = Array.from(activitySummary.entries()).map(([activity, data]) => ({
"Activity": activity,
"Total Completed": data.count,
"Total Units": data.totalUnits,
"Total Amount (₹)": data.totalAmount.toFixed(2),
}));
// Add grand total row
const grandTotal = {
"Activity": "GRAND TOTAL",
"Total Completed": allocations.length,
"Total Units": allocations.reduce((sum, a) => sum + (parseFloat(String(a.units)) || 0), 0),
"Total Amount (₹)": allocations.reduce((sum, a) =>
sum + (parseFloat(String(a.total_amount)) || parseFloat(String(a.rate)) || 0), 0
).toFixed(2),
};
summaryData.push(grandTotal);
const ws2 = XLSX.utils.json_to_sheet(summaryData);
ws2["!cols"] = [
{ wch: 25 }, // Activity
{ wch: 16 }, // Total Completed
{ wch: 12 }, // Total Units
{ wch: 18 }, // Total Amount
];
XLSX.utils.book_append_sheet(wb, ws2, "Summary by Activity");
// ===== Sheet 3: Summary by Contractor =====
const contractorSummary = new Map<string, { count: number; totalAmount: number; employees: Set<string> }>();
allocations.forEach((a) => {
const contractor = a.contractor_name || "Unknown";
const existing = contractorSummary.get(contractor) || { count: 0, totalAmount: 0, employees: new Set() };
existing.count += 1;
existing.totalAmount += parseFloat(String(a.total_amount)) || parseFloat(String(a.rate)) || 0;
if (a.employee_name) existing.employees.add(a.employee_name);
contractorSummary.set(contractor, existing);
});
const contractorData = Array.from(contractorSummary.entries()).map(([contractor, data]) => ({
"Contractor": contractor,
"Employees": data.employees.size,
"Total Completed": data.count,
"Total Amount (₹)": data.totalAmount.toFixed(2),
}));
const ws3 = XLSX.utils.json_to_sheet(contractorData);
ws3["!cols"] = [
{ wch: 25 }, // Contractor
{ wch: 12 }, // Employees
{ wch: 16 }, // Total Completed
{ wch: 18 }, // Total Amount
];
XLSX.utils.book_append_sheet(wb, ws3, "Summary by Contractor");
// Generate filename
const dateStr = dateRange.startDate && dateRange.endDate
? `${dateRange.startDate}_to_${dateRange.endDate}`
: new Date().toISOString().split("T")[0];
const deptStr = departmentName.toLowerCase().replace(/\s+/g, "_");
const filename = `work_report_${deptStr}_${dateStr}.xlsx`;
// Write and download
XLSX.writeFile(wb, filename);
};

View File

@@ -74,11 +74,11 @@ check_db_connection() {
docker exec work_allocation_db mysql -u root -padmin123 -e "SELECT 1" &> /dev/null docker exec work_allocation_db mysql -u root -padmin123 -e "SELECT 1" &> /dev/null
return $? return $?
elif command -v mysql &> /dev/null; then elif command -v mysql &> /dev/null; then
mysql -h localhost -P 3306 -u root -padmin123 -e "SELECT 1" &> /dev/null mysql -h localhost -P 3307 -u root -padmin123 -e "SELECT 1" &> /dev/null
return $? return $?
else else
# Try using nc to check if port is open # Try using nc to check if port is open
nc -z localhost 3306 &> /dev/null nc -z localhost 3307 &> /dev/null
return $? return $?
fi fi
} }

View File

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

259
test_employee_swap.sh Executable file
View File

@@ -0,0 +1,259 @@
#!/bin/bash
# Employee Swap Backend Test Script
# Tests the employee swap functionality including create, complete, and cancel operations
BASE_URL="http://localhost:3000/api"
ADMIN_USER="admin"
ADMIN_PASS="admin123"
echo "========================================"
echo " Employee Swap Backend Test"
echo "========================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print test results
print_result() {
if [ $1 -eq 0 ]; then
echo -e "${GREEN}✓ PASS${NC}: $2"
else
echo -e "${RED}✗ FAIL${NC}: $2"
fi
}
# Step 1: Login as SuperAdmin
echo "Step 1: Logging in as SuperAdmin..."
LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}")
TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.token')
if [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ]; then
print_result 0 "Login successful"
echo " Token: ${TOKEN:0:30}..."
else
print_result 1 "Login failed"
echo " Response: $LOGIN_RESPONSE"
exit 1
fi
echo ""
# Step 2: Get list of employees
echo "Step 2: Fetching employees..."
EMPLOYEES_RESPONSE=$(curl -s -X GET "$BASE_URL/users?role=Employee" \
-H "Authorization: Bearer $TOKEN")
EMPLOYEE_COUNT=$(echo $EMPLOYEES_RESPONSE | jq 'length')
if [ "$EMPLOYEE_COUNT" -gt 0 ]; then
print_result 0 "Found $EMPLOYEE_COUNT employees"
FIRST_EMPLOYEE_ID=$(echo $EMPLOYEES_RESPONSE | jq '.[0].id')
FIRST_EMPLOYEE_NAME=$(echo $EMPLOYEES_RESPONSE | jq -r '.[0].name')
FIRST_EMPLOYEE_DEPT=$(echo $EMPLOYEES_RESPONSE | jq '.[0].department_id')
FIRST_EMPLOYEE_CONTRACTOR=$(echo $EMPLOYEES_RESPONSE | jq '.[0].contractor_id')
echo " Test employee: $FIRST_EMPLOYEE_NAME (ID: $FIRST_EMPLOYEE_ID)"
echo " Current department_id: $FIRST_EMPLOYEE_DEPT"
echo " Current contractor_id: $FIRST_EMPLOYEE_CONTRACTOR"
else
print_result 1 "No employees found"
echo " Response: $EMPLOYEES_RESPONSE"
exit 1
fi
echo ""
# Step 3: Get list of departments
echo "Step 3: Fetching departments..."
DEPTS_RESPONSE=$(curl -s -X GET "$BASE_URL/departments" \
-H "Authorization: Bearer $TOKEN")
DEPT_COUNT=$(echo $DEPTS_RESPONSE | jq 'length')
if [ "$DEPT_COUNT" -gt 1 ]; then
print_result 0 "Found $DEPT_COUNT departments"
# Find a different department than the employee's current one
TARGET_DEPT_ID=$(echo $DEPTS_RESPONSE | jq --argjson current "$FIRST_EMPLOYEE_DEPT" '[.[] | select(.id != $current)][0].id')
TARGET_DEPT_NAME=$(echo $DEPTS_RESPONSE | jq -r --argjson current "$FIRST_EMPLOYEE_DEPT" '[.[] | select(.id != $current)][0].name')
echo " Target department: $TARGET_DEPT_NAME (ID: $TARGET_DEPT_ID)"
else
print_result 1 "Need at least 2 departments for swap test"
exit 1
fi
echo ""
# Step 4: Get contractors in target department
echo "Step 4: Fetching contractors in target department..."
CONTRACTORS_RESPONSE=$(curl -s -X GET "$BASE_URL/users?role=Contractor&departmentId=$TARGET_DEPT_ID" \
-H "Authorization: Bearer $TOKEN")
CONTRACTOR_COUNT=$(echo $CONTRACTORS_RESPONSE | jq 'length')
if [ "$CONTRACTOR_COUNT" -gt 0 ]; then
TARGET_CONTRACTOR_ID=$(echo $CONTRACTORS_RESPONSE | jq '.[0].id')
TARGET_CONTRACTOR_NAME=$(echo $CONTRACTORS_RESPONSE | jq -r '.[0].name')
print_result 0 "Found contractor: $TARGET_CONTRACTOR_NAME (ID: $TARGET_CONTRACTOR_ID)"
else
TARGET_CONTRACTOR_ID="null"
echo -e "${YELLOW}⚠ WARNING${NC}: No contractors in target department, will swap without contractor"
fi
echo ""
# Step 5: Check for existing active swaps
echo "Step 5: Checking for existing active swaps..."
EXISTING_SWAPS=$(curl -s -X GET "$BASE_URL/employee-swaps?status=Active" \
-H "Authorization: Bearer $TOKEN")
ACTIVE_SWAP_FOR_EMPLOYEE=$(echo $EXISTING_SWAPS | jq --argjson empId "$FIRST_EMPLOYEE_ID" '[.[] | select(.employee_id == $empId)][0]')
if [ "$ACTIVE_SWAP_FOR_EMPLOYEE" != "null" ]; then
EXISTING_SWAP_ID=$(echo $ACTIVE_SWAP_FOR_EMPLOYEE | jq '.id')
echo -e "${YELLOW}⚠ WARNING${NC}: Employee already has active swap (ID: $EXISTING_SWAP_ID), cancelling it first..."
CANCEL_RESPONSE=$(curl -s -X PUT "$BASE_URL/employee-swaps/$EXISTING_SWAP_ID/cancel" \
-H "Authorization: Bearer $TOKEN")
echo " Cancelled existing swap"
fi
print_result 0 "No blocking active swaps"
echo ""
# Step 6: Create a new employee swap
echo "Step 6: Creating employee swap..."
TODAY=$(date +%Y-%m-%d)
SWAP_DATA="{
\"employeeId\": $FIRST_EMPLOYEE_ID,
\"targetDepartmentId\": $TARGET_DEPT_ID,
\"targetContractorId\": $TARGET_CONTRACTOR_ID,
\"swapReason\": \"FinishedEarly\",
\"reasonDetails\": \"Test swap from backend test script\",
\"workCompletionPercentage\": 75,
\"swapDate\": \"$TODAY\"
}"
CREATE_RESPONSE=$(curl -s -X POST "$BASE_URL/employee-swaps" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$SWAP_DATA")
SWAP_ID=$(echo $CREATE_RESPONSE | jq '.id')
if [ "$SWAP_ID" != "null" ] && [ -n "$SWAP_ID" ]; then
print_result 0 "Swap created successfully (ID: $SWAP_ID)"
echo " Employee: $(echo $CREATE_RESPONSE | jq -r '.employee_name')"
echo " From: $(echo $CREATE_RESPONSE | jq -r '.original_department_name') → To: $(echo $CREATE_RESPONSE | jq -r '.target_department_name')"
echo " Status: $(echo $CREATE_RESPONSE | jq -r '.status')"
else
print_result 1 "Failed to create swap"
echo " Response: $CREATE_RESPONSE"
exit 1
fi
echo ""
# Step 7: Verify employee's department was updated
echo "Step 7: Verifying employee's department was updated..."
UPDATED_EMPLOYEE=$(curl -s -X GET "$BASE_URL/users/$FIRST_EMPLOYEE_ID" \
-H "Authorization: Bearer $TOKEN")
NEW_DEPT_ID=$(echo $UPDATED_EMPLOYEE | jq '.department_id')
NEW_CONTRACTOR_ID=$(echo $UPDATED_EMPLOYEE | jq '.contractor_id')
if [ "$NEW_DEPT_ID" == "$TARGET_DEPT_ID" ]; then
print_result 0 "Employee department updated correctly"
echo " New department_id: $NEW_DEPT_ID (was: $FIRST_EMPLOYEE_DEPT)"
echo " New contractor_id: $NEW_CONTRACTOR_ID (was: $FIRST_EMPLOYEE_CONTRACTOR)"
else
print_result 1 "Employee department NOT updated"
echo " Expected: $TARGET_DEPT_ID, Got: $NEW_DEPT_ID"
fi
echo ""
# Step 8: Get swap by ID
echo "Step 8: Fetching swap by ID..."
GET_SWAP_RESPONSE=$(curl -s -X GET "$BASE_URL/employee-swaps/$SWAP_ID" \
-H "Authorization: Bearer $TOKEN")
FETCHED_SWAP_ID=$(echo $GET_SWAP_RESPONSE | jq '.id')
if [ "$FETCHED_SWAP_ID" == "$SWAP_ID" ]; then
print_result 0 "Swap fetched successfully"
else
print_result 1 "Failed to fetch swap"
echo " Response: $GET_SWAP_RESPONSE"
fi
echo ""
# Step 9: Complete the swap (return employee to original department)
echo "Step 9: Completing swap (returning employee to original department)..."
COMPLETE_RESPONSE=$(curl -s -X PUT "$BASE_URL/employee-swaps/$SWAP_ID/complete" \
-H "Authorization: Bearer $TOKEN")
COMPLETED_STATUS=$(echo $COMPLETE_RESPONSE | jq -r '.status')
if [ "$COMPLETED_STATUS" == "Completed" ]; then
print_result 0 "Swap completed successfully"
echo " Status: $COMPLETED_STATUS"
else
print_result 1 "Failed to complete swap"
echo " Response: $COMPLETE_RESPONSE"
fi
echo ""
# Step 10: Verify employee returned to original department
echo "Step 10: Verifying employee returned to original department..."
FINAL_EMPLOYEE=$(curl -s -X GET "$BASE_URL/users/$FIRST_EMPLOYEE_ID" \
-H "Authorization: Bearer $TOKEN")
FINAL_DEPT_ID=$(echo $FINAL_EMPLOYEE | jq '.department_id')
FINAL_CONTRACTOR_ID=$(echo $FINAL_EMPLOYEE | jq '.contractor_id')
if [ "$FINAL_DEPT_ID" == "$FIRST_EMPLOYEE_DEPT" ]; then
print_result 0 "Employee returned to original department"
echo " Final department_id: $FINAL_DEPT_ID"
echo " Final contractor_id: $FINAL_CONTRACTOR_ID"
else
print_result 1 "Employee NOT returned to original department"
echo " Expected: $FIRST_EMPLOYEE_DEPT, Got: $FINAL_DEPT_ID"
fi
echo ""
# Step 11: Test swap cancellation
echo "Step 11: Testing swap cancellation..."
# Create another swap
CREATE_RESPONSE2=$(curl -s -X POST "$BASE_URL/employee-swaps" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$SWAP_DATA")
SWAP_ID2=$(echo $CREATE_RESPONSE2 | jq '.id')
if [ "$SWAP_ID2" != "null" ]; then
echo " Created test swap (ID: $SWAP_ID2)"
# Cancel it
CANCEL_RESPONSE=$(curl -s -X PUT "$BASE_URL/employee-swaps/$SWAP_ID2/cancel" \
-H "Authorization: Bearer $TOKEN")
CANCELLED_STATUS=$(echo $CANCEL_RESPONSE | jq -r '.status')
if [ "$CANCELLED_STATUS" == "Cancelled" ]; then
print_result 0 "Swap cancelled successfully"
else
print_result 1 "Failed to cancel swap"
echo " Response: $CANCEL_RESPONSE"
fi
else
print_result 1 "Failed to create swap for cancellation test"
fi
echo ""
# Summary
echo "========================================"
echo " Test Summary"
echo "========================================"
echo -e "${GREEN}All employee swap backend tests completed!${NC}"
echo ""
echo "Tested operations:"
echo " ✓ Login as SuperAdmin"
echo " ✓ Fetch employees and departments"
echo " ✓ Create employee swap"
echo " ✓ Verify employee department/contractor update"
echo " ✓ Fetch swap by ID"
echo " ✓ Complete swap (return to original)"
echo " ✓ Cancel swap"
echo ""

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