Compare commits

...

10 Commits

88 changed files with 15292 additions and 6896 deletions

View File

@@ -1,6 +1,5 @@
# Quick Start Guide
## Start the Application
### Option 1: Use the Start Script (Recommended)
@@ -61,7 +60,7 @@ npm run dev
### Docker Management
```bash
# Stop MySQL
# Stop MySQLclear
docker-compose down
# Stop and remove all data

View File

@@ -1,16 +1,26 @@
# 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:
- [@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-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
- [@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-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc)
uses [SWC](https://swc.rs/) for Fast Refresh
## 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
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_PASSWORD=admin123
DB_NAME=work_allocation
DB_PORT=3306
DB_PORT=3307
# JWT Configuration - CHANGE IN PRODUCTION!
JWT_SECRET=work_alloc_jwt_secret_key_change_in_production_2024

View File

@@ -1,6 +1,7 @@
# 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
@@ -90,7 +91,8 @@ deno task seed
- `GET /api/departments/:id` - Get department
- `GET /api/departments/:id/sub-departments` - Get sub-departments
- `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
@@ -122,21 +124,21 @@ deno task seed
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | 3000 |
| `DB_HOST` | Database host | localhost |
| `DB_USER` | Database user | root |
| `DB_PASSWORD` | Database password | admin123 |
| `DB_NAME` | Database name | work_allocation |
| `DB_PORT` | Database port | 3306 |
| `JWT_SECRET` | JWT signing secret | (change in production!) |
| `JWT_EXPIRES_IN` | Token expiration | 7d |
| `BCRYPT_ROUNDS` | Password hash rounds | 12 |
| `RATE_LIMIT_WINDOW_MS` | Rate limit window | 900000 (15 min) |
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | 100 |
| `CORS_ORIGIN` | Allowed CORS origins | <http://localhost:5173> |
| `NODE_ENV` | Environment | development |
| Variable | Description | Default |
| ------------------------- | ----------------------- | ----------------------- |
| `PORT` | Server port | 3000 |
| `DB_HOST` | Database host | localhost |
| `DB_USER` | Database user | root |
| `DB_PASSWORD` | Database password | admin123 |
| `DB_NAME` | Database name | work_allocation |
| `DB_PORT` | Database port | 3306 |
| `JWT_SECRET` | JWT signing secret | (change in production!) |
| `JWT_EXPIRES_IN` | Token expiration | 7d |
| `BCRYPT_ROUNDS` | Password hash rounds | 12 |
| `RATE_LIMIT_WINDOW_MS` | Rate limit window | 900000 (15 min) |
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | 100 |
| `CORS_ORIGIN` | Allowed CORS origins | <http://localhost:5173> |
| `NODE_ENV` | Environment | development |
## Security Best Practices
@@ -196,14 +198,14 @@ backend-deno/
## Differences from Node.js Backend
| Feature | Node.js | Deno |
|---------|---------|------|
| Runtime | Node.js | Deno |
| Package Manager | npm | Built-in (JSR/npm) |
| TypeScript | Requires compilation | Native support |
| Security | Manual setup | Secure by default |
| Permissions | Full access | Explicit permissions |
| Framework | Express | Oak |
| Feature | Node.js | Deno |
| --------------- | -------------------- | -------------------- |
| Runtime | Node.js | Deno |
| Package Manager | npm | Built-in (JSR/npm) |
| TypeScript | Requires compilation | Native support |
| Security | Manual setup | Secure by default |
| Permissions | Full access | Explicit permissions |
| Framework | Express | Oak |
## 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";
// Load environment variables
@@ -33,14 +33,17 @@ class Database {
async connect(): Promise<Pool> {
if (!this.pool) {
this.pool = createPool(config);
// Test connection
try {
const connection = await this.pool.getConnection();
console.log("✅ Database connected successfully");
connection.release();
} catch (error) {
console.error("❌ Database connection failed:", (error as Error).message);
console.error(
"❌ Database connection failed:",
(error as Error).message,
);
throw error;
}
}
@@ -60,12 +63,39 @@ class Database {
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 [result] = await pool.execute(sql, params);
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> {
if (this.pool) {
await this.pool.end();

View File

@@ -5,36 +5,41 @@ await load({ export: true });
export const config = {
// Server
PORT: parseInt(Deno.env.get("PORT") || "3000"),
// Database
DB_HOST: Deno.env.get("DB_HOST") || "localhost",
DB_USER: Deno.env.get("DB_USER") || "root",
DB_PASSWORD: Deno.env.get("DB_PASSWORD") || "admin123",
DB_NAME: Deno.env.get("DB_NAME") || "work_allocation",
DB_PORT: parseInt(Deno.env.get("DB_PORT") || "3306"),
// 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",
// Security settings
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_MAX_REQUESTS: parseInt(Deno.env.get("RATE_LIMIT_MAX_REQUESTS") || "100"),
RATE_LIMIT_WINDOW_MS: parseInt(
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_ORIGIN: Deno.env.get("CORS_ORIGIN") || "http://localhost:5173",
// Environment
NODE_ENV: Deno.env.get("NODE_ENV") || "development",
isDevelopment(): boolean {
return this.NODE_ENV === "development";
},
isProduction(): boolean {
return this.NODE_ENV === "production";
}
},
};
export default config;

View File

@@ -1,7 +1,12 @@
import { Application, Router } from "@oak/oak";
import { config } from "./config/env.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 authRoutes from "./routes/auth.ts";
@@ -10,6 +15,10 @@ import departmentRoutes from "./routes/departments.ts";
import workAllocationRoutes from "./routes/work-allocations.ts";
import attendanceRoutes from "./routes/attendance.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
await db.connect();
@@ -57,10 +66,46 @@ router.get("/health", (ctx) => {
// Mount API routes
router.use("/api/auth", authRoutes.routes(), authRoutes.allowedMethods());
router.use("/api/users", userRoutes.routes(), userRoutes.allowedMethods());
router.use("/api/departments", departmentRoutes.routes(), 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/departments",
departmentRoutes.routes(),
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
app.use(router.routes());

View File

@@ -1,5 +1,5 @@
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 type { JWTPayload, UserRole } from "../types/index.ts";
@@ -12,14 +12,16 @@ const cryptoKey = await crypto.subtle.importKey(
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
["sign", "verify"],
);
// 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;
let expSeconds = 7 * 24 * 60 * 60; // Default 7 days
if (expiresIn.endsWith("d")) {
expSeconds = parseInt(expiresIn) * 24 * 60 * 60;
} else if (expiresIn.endsWith("h")) {
@@ -27,7 +29,7 @@ export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): P
} else if (expiresIn.endsWith("m")) {
expSeconds = parseInt(expiresIn) * 60;
}
const token = await create(
{ alg: "HS256", typ: "JWT" },
{
@@ -35,9 +37,9 @@ export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): P
exp: getNumericDate(expSeconds),
iat: getNumericDate(0),
},
cryptoKey
cryptoKey,
);
return token;
}
@@ -52,24 +54,27 @@ export async function verifyToken(token: string): Promise<JWTPayload | null> {
}
// 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 token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
if (!token) {
ctx.response.status = 401;
ctx.response.body = { error: "Access token required" };
return;
}
const payload = await verifyToken(token);
if (!payload) {
ctx.response.status = 403;
ctx.response.body = { error: "Invalid or expired token" };
return;
}
// Attach user to context state
ctx.state.user = payload;
await next();
@@ -79,19 +84,19 @@ export async function authenticateToken(ctx: Context, next: Next): Promise<void>
export function authorize(...roles: UserRole[]) {
return async (ctx: Context, next: Next): Promise<void> => {
const user = ctx.state.user as JWTPayload | undefined;
if (!user) {
ctx.response.status = 401;
ctx.response.body = { error: "Unauthorized" };
return;
}
if (!roles.includes(user.role)) {
ctx.response.status = 403;
ctx.response.body = { error: "Insufficient permissions" };
return;
}
await next();
};
}

View File

@@ -10,54 +10,57 @@ export async function rateLimit(ctx: Context, next: Next): Promise<void> {
const now = Date.now();
const windowMs = config.RATE_LIMIT_WINDOW_MS;
const maxRequests = config.RATE_LIMIT_MAX_REQUESTS;
const record = rateLimitStore.get(ip);
if (!record || now > record.resetTime) {
rateLimitStore.set(ip, { count: 1, resetTime: now + windowMs });
} else {
record.count++;
if (record.count > maxRequests) {
ctx.response.status = 429;
ctx.response.body = {
ctx.response.body = {
error: "Too many requests",
retryAfter: Math.ceil((record.resetTime - now) / 1000)
retryAfter: Math.ceil((record.resetTime - now) / 1000),
};
return;
}
}
await next();
}
// Security headers middleware
export async function securityHeaders(ctx: Context, next: Next): Promise<void> {
await next();
// Prevent clickjacking
ctx.response.headers.set("X-Frame-Options", "DENY");
// Prevent MIME type sniffing
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
// XSS protection
ctx.response.headers.set("X-XSS-Protection", "1; mode=block");
// 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
ctx.response.headers.set(
"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)
if (config.isProduction()) {
ctx.response.headers.set(
"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
export async function cors(ctx: Context, next: Next): Promise<void> {
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
if (origin && (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))) {
if (
origin && (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))
) {
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
} else if (config.isDevelopment()) {
// Allow all origins in development
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("Access-Control-Allow-Headers", "Content-Type, Authorization");
ctx.response.headers.set(
"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-Max-Age", "86400");
// Handle preflight requests
if (ctx.request.method === "OPTIONS") {
ctx.response.status = 204;
return;
}
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> {
const start = Date.now();
const { method, url } = ctx.request;
await next();
const ms = Date.now() - start;
const status = ctx.response.status;
// Color code based on status
let statusColor = "\x1b[32m"; // Green for 2xx
if (status >= 400 && status < 500) statusColor = "\x1b[33m"; // Yellow for 4xx
if (status >= 500) statusColor = "\x1b[31m"; // Red for 5xx
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
export function isStrongPassword(password: string): { valid: boolean; message?: string } {
export function isStrongPassword(
password: string,
): { valid: boolean; message?: string } {
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)) {
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)) {
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)) {
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 };
}

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 { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
import type { Attendance, CheckInOutRequest, User } from "../types/index.ts";
import {
authenticateToken,
authorize,
getCurrentUser,
} from "../middleware/auth.ts";
import type {
Attendance,
AttendanceStatus,
CheckInOutRequest,
JWTPayload,
UpdateAttendanceStatusRequest,
User,
} from "../types/index.ts";
const router = new Router();
// Get all attendance records
router.get("/", authenticateToken, async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const params = ctx.request.url.searchParams;
const employeeId = params.get("employeeId");
const startDate = params.get("startDate");
const endDate = params.get("endDate");
const status = params.get("status");
let query = `
router.get(
"/",
authenticateToken,
async (
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
) => {
try {
const currentUser: JWTPayload = getCurrentUser(ctx);
const params: URLSearchParams = ctx.request.url.searchParams;
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.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
@@ -28,53 +44,54 @@ router.get("/", authenticateToken, async (ctx) => {
LEFT JOIN users c ON e.contractor_id = c.id
WHERE 1=1
`;
const queryParams: unknown[] = [];
// Role-based filtering
if (currentUser.role === "Supervisor") {
query += " AND a.supervisor_id = ?";
queryParams.push(currentUser.id);
} else if (currentUser.role === "Employee") {
query += " AND a.employee_id = ?";
queryParams.push(currentUser.id);
const queryParams: unknown[] = [];
// Role-based filtering
if (currentUser.role === "Supervisor") {
query += " AND a.supervisor_id = ?";
queryParams.push(currentUser.id);
} else if (currentUser.role === "Employee") {
query += " AND a.employee_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
router.get("/:id", authenticateToken, async (ctx) => {
try {
const attendanceId = ctx.params.id;
const records = await db.query<Attendance[]>(
`SELECT a.*,
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 users c ON e.contractor_id = c.id
WHERE a.id = ?`,
[attendanceId]
[attendanceId],
);
if (records.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Attendance record not found" };
return;
}
ctx.response.body = records[0];
} catch (error) {
console.error("Get attendance error:", error);
@@ -105,56 +122,65 @@ router.get("/:id", authenticateToken, async (ctx) => {
});
// Check in employee (Supervisor or SuperAdmin)
router.post("/check-in", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CheckInOutRequest;
const { employeeId, workDate } = body;
if (!employeeId || !workDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Employee ID and work date required" };
return;
}
// Verify employee exists
let employeeQuery = "SELECT * FROM users WHERE id = ? AND role = ?";
const employeeParams: unknown[] = [employeeId, "Employee"];
if (currentUser.role === "Supervisor") {
employeeQuery += " AND department_id = ?";
employeeParams.push(currentUser.departmentId);
}
const employees = await db.query<User[]>(employeeQuery, employeeParams);
if (employees.length === 0) {
ctx.response.status = 403;
ctx.response.body = { error: "Employee not found or not in your department" };
return;
}
// Check if already checked in today
const existing = await db.query<Attendance[]>(
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?",
[employeeId, workDate, "CheckedIn"]
);
if (existing.length > 0) {
ctx.response.status = 400;
ctx.response.body = { error: "Employee already checked in today" };
return;
}
const checkInTime = new Date().toISOString().slice(0, 19).replace("T", " ");
const result = await db.execute(
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)",
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"]
);
const newRecord = await db.query<Attendance[]>(
`SELECT a.*,
router.post(
"/check-in",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CheckInOutRequest;
const { employeeId, workDate } = body;
if (!employeeId || !workDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Employee ID and work date required" };
return;
}
// Verify user exists and is Employee or Contractor
let userQuery = "SELECT * FROM users WHERE id = ? AND role IN ('Employee', 'Contractor')";
const userParams: unknown[] = [employeeId];
if (currentUser.role === "Supervisor") {
userQuery += " AND department_id = ?";
userParams.push(currentUser.departmentId);
}
const users = await db.query<User[]>(userQuery, userParams);
if (users.length === 0) {
ctx.response.status = 403;
ctx.response.body = {
error: "User not found, not an Employee/Contractor, or not in your department",
};
return;
}
// Check if there's an active check-in (not yet checked out) for today
const activeCheckIn = await db.query<Attendance[]>(
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = 'CheckedIn'",
[employeeId, workDate],
);
if (activeCheckIn.length > 0) {
ctx.response.status = 400;
ctx.response.body = { error: "User has an active check-in. Please check out first before checking in again." };
return;
}
const checkInTime = new Date().toISOString().slice(0, 19).replace(
"T",
" ",
);
const result = await db.execute(
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)",
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"],
);
const newRecord = await db.query<Attendance[]>(
`SELECT a.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
d.name as department_name,
@@ -165,57 +191,66 @@ router.post("/check-in", authenticateToken, authorize("Supervisor", "SuperAdmin"
LEFT JOIN departments d ON e.department_id = d.id
LEFT JOIN users c ON e.contractor_id = c.id
WHERE a.id = ?`,
[result.insertId]
);
ctx.response.status = 201;
ctx.response.body = newRecord[0];
} catch (error) {
console.error("Check in error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
[result.insertId],
);
ctx.response.status = 201;
ctx.response.body = newRecord[0];
} catch (error) {
console.error("Check in error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Check out employee (Supervisor or SuperAdmin)
router.post("/check-out", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CheckInOutRequest;
const { employeeId, workDate } = body;
if (!employeeId || !workDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Employee ID and work date required" };
return;
}
// Find the check-in record
let query = "SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?";
const params: unknown[] = [employeeId, workDate, "CheckedIn"];
if (currentUser.role === "Supervisor") {
query += " AND supervisor_id = ?";
params.push(currentUser.id);
}
const records = await db.query<Attendance[]>(query, params);
if (records.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "No check-in record found for today" };
return;
}
const checkOutTime = new Date().toISOString().slice(0, 19).replace("T", " ");
await db.execute(
"UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?",
[checkOutTime, "CheckedOut", records[0].id]
);
const updatedRecord = await db.query<Attendance[]>(
`SELECT a.*,
router.post(
"/check-out",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CheckInOutRequest;
const { employeeId, workDate } = body;
if (!employeeId || !workDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Employee ID and work date required" };
return;
}
// Find the check-in record
let query =
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?";
const params: unknown[] = [employeeId, workDate, "CheckedIn"];
if (currentUser.role === "Supervisor") {
query += " AND supervisor_id = ?";
params.push(currentUser.id);
}
const records = await db.query<Attendance[]>(query, params);
if (records.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "No check-in record found for today" };
return;
}
const checkOutTime = new Date().toISOString().slice(0, 19).replace(
"T",
" ",
);
await db.execute(
"UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?",
[checkOutTime, "CheckedOut", records[0].id],
);
const updatedRecord = await db.query<Attendance[]>(
`SELECT a.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
d.name as department_name,
@@ -226,27 +261,193 @@ router.post("/check-out", authenticateToken, authorize("Supervisor", "SuperAdmin
LEFT JOIN departments d ON e.department_id = d.id
LEFT JOIN users c ON e.contractor_id = c.id
WHERE a.id = ?`,
[records[0].id]
);
ctx.response.body = updatedRecord[0];
} catch (error) {
console.error("Check out error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
[records[0].id],
);
ctx.response.body = updatedRecord[0];
} catch (error) {
console.error("Check out error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Update attendance status (mark as Absent, HalfDay, Late)
router.put(
"/:id/status",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const attendanceId = ctx.params.id;
const body = await ctx.request.body
.json() as UpdateAttendanceStatusRequest;
const { status, remark } = body;
// Validate status
const validStatuses: AttendanceStatus[] = [
"CheckedIn",
"CheckedOut",
"Absent",
"HalfDay",
"Late",
];
if (!validStatuses.includes(status)) {
ctx.response.status = 400;
ctx.response.body = {
error:
"Invalid status. Must be one of: CheckedIn, CheckedOut, Absent, HalfDay, Late",
};
return;
}
// Check if record exists
const existing = await db.query<Attendance[]>(
"SELECT * FROM attendance WHERE id = ?",
[attendanceId],
);
if (existing.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Attendance record not found" };
return;
}
// Update the status
await db.execute(
"UPDATE attendance SET status = ?, remark = ? WHERE id = ?",
[status, remark || null, attendanceId],
);
const updatedRecord = await db.query<Attendance[]>(
`SELECT a.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
d.name as department_name,
c.name as contractor_name
FROM attendance a
JOIN users e ON a.employee_id = e.id
JOIN users s ON a.supervisor_id = s.id
LEFT JOIN departments d ON e.department_id = d.id
LEFT JOIN users c ON e.contractor_id = c.id
WHERE a.id = ?`,
[attendanceId],
);
ctx.response.body = updatedRecord[0];
} catch (error) {
console.error("Update attendance status error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Mark employee as absent (create absent record)
router.post(
"/mark-absent",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json();
const { employeeId, workDate, remark } = body;
if (!employeeId || !workDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Employee ID and work date required" };
return;
}
// Check if record already exists for this date
const existing = await db.query<Attendance[]>(
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ?",
[employeeId, workDate],
);
if (existing.length > 0) {
// Update existing record to Absent
await db.execute(
"UPDATE attendance SET status = ?, remark = ? WHERE id = ?",
["Absent", remark || "Marked absent", existing[0].id],
);
const updatedRecord = await db.query<Attendance[]>(
`SELECT a.*,
e.name as employee_name, e.username as employee_username,
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
router.get("/summary/stats", authenticateToken, async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const params = ctx.request.url.searchParams;
const startDate = params.get("startDate");
const endDate = params.get("endDate");
const departmentId = params.get("departmentId");
let query = `
router.get(
"/summary/stats",
authenticateToken,
async (
ctx: RouterContext<
"/summary/stats",
Record<string | number, string | undefined>,
State
>,
) => {
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
COUNT(DISTINCT a.employee_id) as total_employees,
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
WHERE 1=1
`;
const queryParams: unknown[] = [];
if (currentUser.role === "Supervisor") {
query += " AND a.supervisor_id = ?";
queryParams.push(currentUser.id);
const queryParams: (number | string)[] = [];
if (currentUser.role === "Supervisor") {
query += " AND a.supervisor_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;

View File

@@ -1,10 +1,23 @@
import { Router } from "@oak/oak";
import { hash, compare } from "bcrypt";
import { compare, genSalt, hash } from "bcrypt";
import { db } from "../config/database.ts";
import { config } from "../config/env.ts";
import { authenticateToken, generateToken, getCurrentUser } from "../middleware/auth.ts";
import { sanitizeInput, isValidEmail, isStrongPassword } from "../middleware/security.ts";
import type { User, LoginRequest, ChangePasswordRequest } 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,
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();
@@ -13,41 +26,41 @@ router.post("/login", async (ctx) => {
try {
const body = await ctx.request.body.json() as LoginRequest;
const { username, password } = body;
// Input validation
if (!username || !password) {
ctx.response.status = 400;
ctx.response.body = { error: "Username and password required" };
return;
}
// Sanitize input
const sanitizedUsername = sanitizeInput(username);
// Query user
const users = await db.query<User[]>(
"SELECT * FROM users WHERE username = ? AND is_active = TRUE",
[sanitizedUsername]
[sanitizedUsername],
);
if (users.length === 0) {
// Use generic message to prevent user enumeration
ctx.response.status = 401;
ctx.response.body = { error: "Invalid credentials" };
return;
}
const user = users[0];
// Verify password
const validPassword = await compare(password, user.password!);
if (!validPassword) {
ctx.response.status = 401;
ctx.response.body = { error: "Invalid credentials" };
return;
}
// Generate JWT token
const token = await generateToken({
id: user.id,
@@ -55,10 +68,10 @@ router.post("/login", async (ctx) => {
role: user.role,
departmentId: user.department_id,
});
// Return user data without password
const { password: _, ...userWithoutPassword } = user;
ctx.response.body = {
token,
user: userWithoutPassword,
@@ -74,18 +87,18 @@ router.post("/login", async (ctx) => {
router.get("/me", authenticateToken, async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const users = await db.query<User[]>(
"SELECT id, username, name, email, role, department_id, contractor_id, is_active FROM users WHERE id = ?",
[currentUser.id]
[currentUser.id],
);
if (users.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
return;
}
ctx.response.body = users[0];
} catch (error) {
console.error("Get user error:", error);
@@ -100,14 +113,14 @@ router.post("/change-password", authenticateToken, async (ctx) => {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as ChangePasswordRequest;
const { currentPassword, newPassword } = body;
// Input validation
if (!currentPassword || !newPassword) {
ctx.response.status = 400;
ctx.response.body = { error: "Current and new password required" };
return;
}
// Validate new password strength (only enforce in production or if explicitly enabled)
if (config.isProduction()) {
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" };
return;
}
// Get current password hash
const users = await db.query<User[]>(
"SELECT password FROM users WHERE id = ?",
[currentUser.id]
[currentUser.id],
);
if (users.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
return;
}
// Verify current password
const validPassword = await compare(currentPassword, users[0].password!);
if (!validPassword) {
ctx.response.status = 401;
ctx.response.body = { error: "Current password is incorrect" };
return;
}
// Hash new password with configured rounds
const hashedPassword = await hash(newPassword, config.BCRYPT_ROUNDS);
const hashedPassword = await hashPassword(newPassword);
// Update password
await db.execute(
"UPDATE users SET password = ? WHERE id = ?",
[hashedPassword, currentUser.id]
[hashedPassword, currentUser.id],
);
ctx.response.body = { message: "Password changed successfully" };
} catch (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 { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
import {
authenticateToken,
authorize,
getCurrentUser,
} from "../middleware/auth.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();
// Get contractor rates
router.get("/", authenticateToken, async (ctx) => {
try {
const params = ctx.request.url.searchParams;
const contractorId = params.get("contractorId");
const subDepartmentId = params.get("subDepartmentId");
let query = `
router.get(
"/",
authenticateToken,
async (
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
) => {
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.*,
u.name as contractor_name, u.username as contractor_username,
sd.name as sub_department_name,
d.name as department_name
d.name as department_name,
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 queryParams: unknown[] = [];
if (contractorId) {
query += " AND cr.contractor_id = ?";
queryParams.push(contractorId);
const queryParams: unknown[] = [];
if (contractorId) {
query += " AND cr.contractor_id = ?";
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
router.get("/contractor/:contractorId/current", authenticateToken, async (ctx) => {
try {
const contractorId = ctx.params.contractorId;
const params = ctx.request.url.searchParams;
const subDepartmentId = params.get("subDepartmentId");
let query = `
router.get(
"/contractor/:contractorId/current",
authenticateToken,
async (
ctx: RouterContext<
"/contractor/:contractorId/current",
{ 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.*,
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
JOIN users u ON cr.contractor_id = u.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 = ?
`;
const queryParams: unknown[] = [contractorId];
if (subDepartmentId) {
query += " AND cr.sub_department_id = ?";
queryParams.push(subDepartmentId);
const queryParams: unknown[] = [contractorId];
if (subDepartmentId) {
query += " AND cr.sub_department_id = ?";
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)
router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CreateContractorRateRequest;
const { contractorId, subDepartmentId, activity, rate, effectiveDate } = body;
if (!contractorId || !rate || !effectiveDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Missing required fields (contractorId, rate, effectiveDate)" };
return;
}
// Verify contractor exists
const contractors = await db.query<User[]>(
"SELECT * FROM users WHERE id = ? AND role = ?",
[contractorId, "Contractor"]
);
if (contractors.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Contractor not found" };
return;
}
// Supervisors can only set rates for contractors in their department
if (currentUser.role === "Supervisor" && contractors[0].department_id !== currentUser.departmentId) {
ctx.response.status = 403;
ctx.response.body = { error: "Contractor not in your department" };
return;
}
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
const result = 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 = await db.query<ContractorRate[]>(
`SELECT cr.*,
router.post(
"/",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CreateContractorRateRequest;
const { contractorId, subDepartmentId, activity, rate, effectiveDate } =
body;
if (!contractorId || !rate || !effectiveDate) {
ctx.response.status = 400;
ctx.response.body = {
error: "Missing required fields (contractorId, rate, effectiveDate)",
};
return;
}
// Verify contractor exists
const contractors = await db.query<User[]>(
"SELECT * FROM users WHERE id = ? AND role = ?",
[contractorId, "Contractor"],
);
if (contractors.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Contractor not found" };
return;
}
// Supervisors can only set rates for contractors in their department
if (
currentUser.role === "Supervisor" &&
contractors[0].department_id !== currentUser.departmentId
) {
ctx.response.status = 403;
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,
sd.name as sub_department_name
sd.name as sub_department_name,
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 activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
WHERE cr.id = ?`,
[result.insertId]
);
ctx.response.status = 201;
ctx.response.body = newRate[0];
} catch (error) {
console.error("Set contractor rate error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
[result.insertId],
);
ctx.response.status = 201;
ctx.response.body = newRate[0];
} catch (error) {
console.error("Set contractor rate error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Update contractor rate
router.put("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
const rateId = ctx.params.id;
const body = await ctx.request.body.json() as { rate?: number; activity?: string; effectiveDate?: string };
const { rate, activity, effectiveDate } = body;
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;
}
const updates: string[] = [];
const params: unknown[] = [];
if (rate !== undefined) {
updates.push("rate = ?");
params.push(rate);
}
if (activity !== undefined) {
updates.push("activity = ?");
params.push(sanitizeInput(activity));
}
if (effectiveDate !== undefined) {
updates.push("effective_date = ?");
params.push(effectiveDate);
}
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.*,
router.put(
"/:id",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (
ctx: RouterContext<
"/:id",
{ id: string } & Record<string | number, string | undefined>,
State
>,
) => {
try {
const rateId = ctx.params.id;
const body = await ctx.request.body.json() as {
rate?: number;
activity?: string;
effectiveDate?: string;
};
const { rate, activity, effectiveDate } = body;
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;
}
const updates: string[] = [];
const params: unknown[] = [];
if (rate !== undefined) {
updates.push("rate = ?");
params.push(rate);
}
if (activity !== undefined) {
updates.push("activity = ?");
params.push(sanitizeInput(activity));
}
if (effectiveDate !== undefined) {
updates.push("effective_date = ?");
params.push(effectiveDate);
}
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,
sd.name as sub_department_name
sd.name as sub_department_name,
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 activities a ON a.sub_department_id = cr.sub_department_id AND a.name = cr.activity
WHERE cr.id = ?`,
[rateId]
);
ctx.response.body = updatedRate[0];
} catch (error) {
console.error("Update contractor rate error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
[rateId],
);
ctx.response.body = updatedRate[0];
} catch (error) {
console.error("Update contractor rate error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Delete contractor rate
router.delete("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
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;
router.delete(
"/:id",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (
ctx: RouterContext<
"/:id",
{ id: string } & Record<string | number, string | undefined>,
State
>,
) => {
try {
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;

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 { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
import {
authenticateToken,
authorize,
getCurrentUser,
} from "../middleware/auth.ts";
import { sanitizeInput } from "../middleware/security.ts";
import type { Department, SubDepartment } from "../types/index.ts";
const router = new Router();
// Get all departments
router.get("/", authenticateToken, async (ctx) => {
router.get("/", authenticateToken, async (ctx: Context) => {
try {
const departments = await db.query<Department[]>(
"SELECT * FROM departments ORDER BY name"
"SELECT * FROM departments ORDER BY name",
);
ctx.response.body = departments;
} catch (error) {
@@ -21,21 +25,21 @@ router.get("/", authenticateToken, async (ctx) => {
});
// Get department by ID
router.get("/:id", authenticateToken, async (ctx) => {
router.get("/:id", authenticateToken, async (ctx: RouterContext<"/:id">) => {
try {
const deptId = ctx.params.id;
const departments = await db.query<Department[]>(
"SELECT * FROM departments WHERE id = ?",
[deptId]
[deptId],
);
if (departments.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Department not found" };
return;
}
ctx.response.body = departments[0];
} catch (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
router.get("/:id/sub-departments", authenticateToken, async (ctx) => {
try {
const deptId = ctx.params.id;
const subDepartments = await db.query<SubDepartment[]>(
"SELECT * FROM sub_departments WHERE department_id = ? ORDER BY name",
[deptId]
);
ctx.response.body = subDepartments;
} catch (error) {
console.error("Get sub-departments error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
router.get(
"/:id/sub-departments",
authenticateToken,
async (ctx: RouterContext<"/:id/sub-departments">) => {
try {
const deptId = ctx.params.id;
const subDepartments = await db.query<SubDepartment[]>(
"SELECT * FROM sub_departments WHERE department_id = ? ORDER BY name",
[deptId],
);
ctx.response.body = subDepartments;
} catch (error) {
console.error("Get sub-departments error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// 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 {
const body = await ctx.request.body.json() as { name: string };
const { name } = body;
if (!name) {
const user = getCurrentUser(ctx);
const body = await ctx.request.body.json() as {
department_id: number;
name: string;
};
const { department_id, name } = body;
if (!name || !department_id) {
ctx.response.status = 400;
ctx.response.body = { error: "Department name required" };
ctx.response.body = { error: "Department ID and name are required" };
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 result = await db.execute(
"INSERT INTO departments (name) VALUES (?)",
[sanitizedName]
"INSERT INTO sub_departments (department_id, name) VALUES (?, ?)",
[department_id, sanitizedName],
);
const newDepartment = await db.query<Department[]>(
"SELECT * FROM departments WHERE id = ?",
[result.insertId]
const newSubDepartment = await db.query<SubDepartment[]>(
"SELECT * FROM sub_departments WHERE id = ?",
[result.insertId],
);
ctx.response.status = 201;
ctx.response.body = newDepartment[0];
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: "Department already exists" };
ctx.response.body = {
error: "Sub-department already exists in this department",
};
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);
ctx.response.status = 500;
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;

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 { hash } from "bcrypt";
import { type Context, Router } from "@oak/oak";
import { genSalt, hash } from "bcrypt";
import { db } from "../config/database.ts";
import { config } from "../config/env.ts";
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
import { sanitizeInput, isValidEmail } from "../middleware/security.ts";
import type { User, CreateUserRequest, UpdateUserRequest } from "../types/index.ts";
// Helper function to hash password with proper salt generation
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();
// Get all users (with filters)
router.get("/", authenticateToken, async (ctx) => {
router.get("/", authenticateToken, async (ctx: Context) => {
try {
const currentUser = getCurrentUser(ctx);
const params = ctx.request.url.searchParams;
const role = params.get("role");
const departmentId = params.get("departmentId");
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,
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
@@ -27,25 +44,25 @@ router.get("/", authenticateToken, async (ctx) => {
WHERE 1=1
`;
const queryParams: unknown[] = [];
// Supervisors can only see users in their department
if (currentUser.role === "Supervisor") {
query += " AND u.department_id = ?";
queryParams.push(currentUser.departmentId);
}
if (role) {
query += " AND u.role = ?";
queryParams.push(role);
}
if (departmentId) {
query += " AND u.department_id = ?";
queryParams.push(departmentId);
}
query += " ORDER BY u.created_at DESC";
const users = await db.query<User[]>(query, queryParams);
ctx.response.body = users;
} catch (error) {
@@ -56,36 +73,42 @@ router.get("/", authenticateToken, async (ctx) => {
});
// Get user by ID
router.get("/:id", authenticateToken, async (ctx) => {
router.get("/:id", authenticateToken, async (ctx: Context) => {
try {
const currentUser = getCurrentUser(ctx);
const userId = ctx.params.id;
const users = 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]
[userId],
);
if (users.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
return;
}
// 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.body = { error: "Access denied" };
return;
}
ctx.response.body = users[0];
} catch (error) {
console.error("Get user error:", error);
@@ -95,218 +118,334 @@ router.get("/:id", authenticateToken, async (ctx) => {
});
// Create user
router.post("/", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CreateUserRequest;
const { username, name, email, password, role, departmentId, contractorId } = body;
// Input validation
if (!username || !name || !email || !password || !role) {
ctx.response.status = 400;
ctx.response.body = { error: "Missing required fields" };
return;
}
// Sanitize inputs
const sanitizedUsername = sanitizeInput(username);
const sanitizedName = sanitizeInput(name);
const sanitizedEmail = sanitizeInput(email);
// Validate email
if (!isValidEmail(sanitizedEmail)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid email format" };
return;
}
// Supervisors can only create users in their department
if (currentUser.role === "Supervisor") {
if (departmentId !== currentUser.departmentId) {
ctx.response.status = 403;
ctx.response.body = { error: "Can only create users in your department" };
return;
}
if (role === "SuperAdmin" || role === "Supervisor") {
ctx.response.status = 403;
ctx.response.body = { error: "Cannot create admin or supervisor users" };
return;
}
}
// Hash password
const hashedPassword = await 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" };
}
});
router.post(
"/",
authenticateToken,
authorize("SuperAdmin", "Supervisor"),
async (ctx: Context) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CreateUserRequest;
const {
username,
name,
email,
password,
role,
departmentId,
contractorId,
phoneNumber,
aadharNumber,
bankAccountNumber,
bankName,
bankIfsc,
contractorAgreementNumber,
pfNumber,
esicNumber,
} = body;
// Update user
router.put("/:id", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
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 } = 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" };
// Input validation
if (!username || !name || !email || !password || !role) {
ctx.response.status = 400;
ctx.response.body = { error: "Missing required fields" };
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)) {
// Sanitize inputs
const sanitizedUsername = sanitizeInput(username);
const sanitizedName = sanitizeInput(name);
const sanitizedEmail = sanitizeInput(email);
// Validate email
if (!isValidEmail(sanitizedEmail)) {
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);
}
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,
// Supervisors can only create users in their department
if (currentUser.role === "Supervisor") {
if (departmentId !== currentUser.departmentId) {
ctx.response.status = 403;
ctx.response.body = {
error: "Can only create users in your department",
};
return;
}
if (role === "SuperAdmin" || role === "Supervisor") {
ctx.response.status = 403;
ctx.response.body = {
error: "Cannot create admin or supervisor users",
};
return;
}
}
// Hash password
const hashedPassword = await hashPassword(password);
const result = await db.execute(
`INSERT INTO users (username, name, email, password, role, department_id, contractor_id,
phone_number, aadhar_number, bank_account_number, bank_name, bank_ifsc,
contractor_agreement_number, pf_number, esic_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
sanitizedUsername,
sanitizedName,
sanitizedEmail,
hashedPassword,
role,
departmentId || null,
contractorId || null,
phoneNumber || null,
aadharNumber || null,
bankAccountNumber || null,
bankName || null,
bankIfsc || null,
contractorAgreementNumber || null,
pfNumber || null,
esicNumber || null,
],
);
const newUser = await db.query<User[]>(
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
u.contractor_id, u.is_active, u.created_at,
u.phone_number, u.aadhar_number, u.bank_account_number,
u.bank_name, u.bank_ifsc,
u.contractor_agreement_number, u.pf_number, u.esic_number,
d.name as department_name,
c.name as contractor_name
FROM users u
LEFT JOIN departments d ON u.department_id = d.id
LEFT JOIN users c ON u.contractor_id = c.id
WHERE u.id = ?`,
[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" };
}
});
[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
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
router.delete("/:id", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const userId = ctx.params.id;
const users = await db.query<User[]>(
"SELECT * FROM users WHERE id = ?",
[userId]
);
if (users.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
return;
}
// Supervisors can only delete users in their department
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" };
router.delete(
"/:id",
authenticateToken,
authorize("SuperAdmin", "Supervisor"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const userId = ctx.params.id;
const users = await db.query<User[]>(
"SELECT * FROM users WHERE id = ?",
[userId],
);
if (users.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
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;
// Supervisors can only delete users in their department
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;
}
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;

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 { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
import {
authenticateToken,
authorize,
getCurrentUser,
} from "../middleware/auth.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();
// Get all work allocations
router.get("/", authenticateToken, async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const params = ctx.request.url.searchParams;
const employeeId = params.get("employeeId");
const status = params.get("status");
const departmentId = params.get("departmentId");
let query = `
router.get(
"/",
authenticateToken,
async (
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
) => {
try {
const currentUser: JWTPayload = getCurrentUser(ctx);
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.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
@@ -30,52 +44,53 @@ router.get("/", authenticateToken, async (ctx) => {
LEFT JOIN departments d ON e.department_id = d.id
WHERE 1=1
`;
const queryParams: unknown[] = [];
// Role-based filtering
if (currentUser.role === "Supervisor") {
query += " AND wa.supervisor_id = ?";
queryParams.push(currentUser.id);
} else if (currentUser.role === "Employee") {
query += " AND wa.employee_id = ?";
queryParams.push(currentUser.id);
} else if (currentUser.role === "Contractor") {
query += " AND wa.contractor_id = ?";
queryParams.push(currentUser.id);
const queryParams: unknown[] = [];
// Role-based filtering
if (currentUser.role === "Supervisor") {
query += " AND wa.supervisor_id = ?";
queryParams.push(currentUser.id);
} else if (currentUser.role === "Employee") {
query += " AND wa.employee_id = ?";
queryParams.push(currentUser.id);
} else if (currentUser.role === "Contractor") {
query += " AND wa.contractor_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
router.get("/:id", authenticateToken, async (ctx) => {
router.get("/:id", authenticateToken, async (ctx: RouterContext<"/:id">) => {
try {
const allocationId = ctx.params.id;
const allocations = await db.query<WorkAllocation[]>(
const allocationId: string | undefined = ctx.params.id;
const allocations: WorkAllocation[] = await db.query<WorkAllocation[]>(
`SELECT wa.*,
e.name as employee_name, e.username as employee_username,
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 departments d ON e.department_id = d.id
WHERE wa.id = ?`,
[allocationId]
[allocationId],
);
if (allocations.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Work allocation not found" };
return;
}
ctx.response.body = allocations[0];
} catch (error) {
console.error("Get work allocation error:", error);
@@ -107,57 +122,92 @@ router.get("/:id", authenticateToken, async (ctx) => {
});
// Create work allocation (Supervisor or SuperAdmin)
router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CreateWorkAllocationRequest;
const { employeeId, contractorId, subDepartmentId, activity, description, assignedDate, rate, units, totalAmount, departmentId } = body;
if (!employeeId || !contractorId || !assignedDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Missing required fields" };
return;
}
// Verify employee exists
let employeeQuery = "SELECT * FROM users WHERE id = ?";
const employeeParams: unknown[] = [employeeId];
if (currentUser.role === "Supervisor") {
employeeQuery += " AND department_id = ?";
employeeParams.push(currentUser.departmentId);
}
const employees = await db.query<{ id: number }[]>(employeeQuery, employeeParams);
if (employees.length === 0) {
ctx.response.status = 403;
ctx.response.body = { error: "Employee not found or not in your department" };
return;
}
// 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]
router.post(
"/",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CreateWorkAllocationRequest;
const {
employeeId,
contractorId,
subDepartmentId,
activity,
description,
assignedDate,
rate,
units,
totalAmount,
departmentId,
} = body;
if (!employeeId || !contractorId || !assignedDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Missing required fields" };
return;
}
// Verify employee exists
let employeeQuery = "SELECT * FROM users WHERE id = ?";
const employeeParams: unknown[] = [employeeId];
if (currentUser.role === "Supervisor") {
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;
}
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
const sanitizedDescription = description ? sanitizeInput(description) : null;
const result = await db.execute(
`INSERT INTO work_allocations
if (employees.length === 0) {
ctx.response.status = 403;
ctx.response.body = {
error: "Employee not found or not in your department",
};
return;
}
// 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[employeeId, currentUser.id, contractorId, subDepartmentId || null, sanitizedActivity, sanitizedDescription, assignedDate, finalRate, units || null, totalAmount || null]
);
const newAllocation = await db.query<WorkAllocation[]>(
`SELECT wa.*,
[
employeeId,
currentUser.id,
contractorId,
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,
s.name as supervisor_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 departments d ON e.department_id = d.id
WHERE wa.id = ?`,
[result.insertId]
);
ctx.response.status = 201;
ctx.response.body = newAllocation[0];
} catch (error) {
console.error("Create work allocation error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
[result.insertId],
);
ctx.response.status = 201;
ctx.response.body = newAllocation[0];
} catch (error) {
console.error("Create work allocation error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Update work allocation status (Supervisor or SuperAdmin)
router.put("/:id/status", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const allocationId = ctx.params.id;
const body = await ctx.request.body.json() as { status: string; completionDate?: string };
const { status, completionDate } = body;
if (!status) {
ctx.response.status = 400;
ctx.response.body = { error: "Status required" };
return;
}
// Verify allocation exists and user has access
let query = "SELECT * FROM work_allocations WHERE 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(
"UPDATE work_allocations SET status = ?, completion_date = ? WHERE id = ?",
[status, completionDate || null, allocationId]
);
const updatedAllocation = await db.query<WorkAllocation[]>(
`SELECT wa.*,
router.put(
"/:id/status",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const allocationId = ctx.params.id;
const body = await ctx.request.body.json() as {
status: string;
completionDate?: string;
};
const { status, completionDate } = body;
if (!status) {
ctx.response.status = 400;
ctx.response.body = { error: "Status required" };
return;
}
// Verify allocation exists and user has access
let query = "SELECT * FROM work_allocations WHERE 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(
"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,
s.name as supervisor_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 departments d ON e.department_id = d.id
WHERE wa.id = ?`,
[allocationId]
);
ctx.response.body = updatedAllocation[0];
} catch (error) {
console.error("Update work allocation error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
[allocationId],
);
ctx.response.body = updatedAllocation[0];
} catch (error) {
console.error("Update work allocation error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
},
);
// Delete work allocation (Supervisor or SuperAdmin)
router.delete("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const allocationId = ctx.params.id;
// Verify allocation exists and user has access
let query = "SELECT * FROM work_allocations WHERE id = ?";
const params: unknown[] = [allocationId];
if (currentUser.role === "Supervisor") {
query += " AND supervisor_id = ?";
params.push(currentUser.id);
router.delete(
"/:id",
authenticateToken,
authorize("Supervisor", "SuperAdmin"),
async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const allocationId = ctx.params.id;
// Verify allocation exists and user has access
let query = "SELECT * FROM work_allocations WHERE 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;

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,16 @@ export interface User {
created_at: Date;
department_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 {
@@ -41,7 +51,11 @@ export interface SubDepartment {
}
// Work allocation types
export type AllocationStatus = "Pending" | "InProgress" | "Completed" | "Cancelled";
export type AllocationStatus =
| "Pending"
| "InProgress"
| "Completed"
| "Cancelled";
export interface WorkAllocation {
id: number;
@@ -66,7 +80,12 @@ export interface WorkAllocation {
}
// Attendance types
export type AttendanceStatus = "CheckedIn" | "CheckedOut" | "Absent";
export type AttendanceStatus =
| "CheckedIn"
| "CheckedOut"
| "Absent"
| "HalfDay"
| "Late";
export interface Attendance {
id: number;
@@ -76,6 +95,7 @@ export interface Attendance {
check_out_time: Date | null;
work_date: Date;
status: AttendanceStatus;
remark?: string | null;
created_at: Date;
employee_name?: string;
supervisor_name?: string;
@@ -83,6 +103,49 @@ export interface Attendance {
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
export interface ContractorRate {
id: number;
@@ -127,6 +190,16 @@ export interface CreateUserRequest {
role: UserRole;
departmentId?: 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 {
@@ -136,6 +209,16 @@ export interface UpdateUserRequest {
departmentId?: number | null;
contractorId?: number | null;
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 {
@@ -168,3 +251,25 @@ export interface CreateContractorRateRequest {
rate: number;
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
-- 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 (
id INT PRIMARY KEY AUTO_INCREMENT,
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Sub-departments table (for Groundnut department)
-- Create sub_departments table
CREATE TABLE IF NOT EXISTS sub_departments (
id INT PRIMARY KEY AUTO_INCREMENT,
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
name VARCHAR(200) NOT NULL,
primary_activity VARCHAR(200) NOT NULL,
name VARCHAR(100) 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
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 (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
email VARCHAR(200) NOT NULL UNIQUE,
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) 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,
-- 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,
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 (rates per contractor + sub-department combination)
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 work_allocations table
CREATE TABLE IF NOT EXISTS work_allocations (
id INT PRIMARY KEY AUTO_INCREMENT,
id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL,
supervisor_id INT NOT NULL,
contractor_id INT NOT NULL,
sub_department_id INT,
activity VARCHAR(100),
activity VARCHAR(255),
description TEXT,
assigned_date DATE NOT NULL,
status ENUM('Pending', 'InProgress', 'Completed', 'Cancelled') DEFAULT 'Pending',
completion_date DATE,
status ENUM('Pending', 'InProgress', 'Completed', 'Cancelled') DEFAULT 'Pending',
rate DECIMAL(10, 2),
units DECIMAL(10, 2),
total_amount 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 attendance table
CREATE TABLE IF NOT EXISTS attendance (
id INT PRIMARY KEY AUTO_INCREMENT,
id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL,
supervisor_id INT NOT NULL,
check_in_time DATETIME NOT NULL,
check_in_time DATETIME,
check_out_time DATETIME,
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,
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 (supervisor_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY unique_attendance (employee_id, work_date)
);
-- Insert default departments
INSERT IGNORE INTO departments (name) VALUES
('Tudki'),
('Dana'),
('Groundnut');
-- Create employee_swaps table for tracking employee department transfers
CREATE TABLE IF NOT EXISTS employee_swaps (
id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL,
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
INSERT IGNORE 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';
-- Create contractor_rates table
CREATE TABLE IF NOT EXISTS contractor_rates (
id INT AUTO_INCREMENT PRIMARY KEY,
contractor_id INT NOT NULL,
sub_department_id INT,
activity VARCHAR(255),
rate DECIMAL(10, 2) NOT NULL,
effective_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT 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
);
-- Note: Admin user will be created by running: npm run seed
-- This ensures the password is properly hashed with bcrypt
-- 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 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:
mysql:
image: mysql:8.0
@@ -9,12 +7,21 @@ services:
MYSQL_ROOT_PASSWORD: admin123
MYSQL_DATABASE: work_allocation
ports:
- "3306:3306"
- "3307:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./backend/database/init-schema.sql:/docker-entrypoint-initdb.d/01-init-schema.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-padmin123"]
test: [
"CMD",
"mysqladmin",
"ping",
"-h",
"localhost",
"-u",
"root",
"-padmin123",
]
interval: 5s
timeout: 5s
retries: 10

View File

@@ -1,13 +1,13 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
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: [
js.configs.recommended,
...tseslint.configs.recommended,
@@ -16,21 +16,26 @@ export default tseslint.config(
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
ecmaVersion: "latest",
ecmaFeatures: { tsx: true },
sourceType: "module",
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-unused-vars': 'off',
"react-refresh/only-export-components": ["warn", {
allowConstantExport: true,
}],
"@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">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>my-dashboard</title>
<title>WorkAllocation</title>
</head>
<body>
<div id="root"></div>

113
package-lock.json generated
View File

@@ -8,10 +8,11 @@
"name": "my-dashboard",
"version": "0.0.0",
"dependencies": {
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.5.0"
"lucide-react": "0.555.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"recharts": "3.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -1769,6 +1770,15 @@
"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": {
"version": "6.12.6",
"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": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2054,6 +2077,15 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2090,6 +2122,18 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"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": {
"version": "7.0.6",
"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",
"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": {
"version": "5.3.4",
"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",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3720,6 +3774,18 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4105,6 +4171,24 @@
"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": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4113,6 +4197,27 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,10 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.5.0"
"lucide-react": "0.555.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"recharts": "3.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@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: {},
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 { AuthProvider, useAuth } from './contexts/AuthContext';
import { Sidebar } from './components/layout/Sidebar';
import { Header } from './components/layout/Header';
import { DashboardPage } from './pages/DashboardPage';
import { UsersPage } from './pages/UsersPage';
import { WorkAllocationPage } from './pages/WorkAllocationPage';
import { AttendancePage } from './pages/AttendancePage';
import { RatesPage } from './pages/RatesPage';
import { LoginPage } from './pages/LoginPage';
import React, { useState } from "react";
import { AuthProvider } from "./contexts/AuthContext.tsx";
import { useAuth } from "./contexts/authContext.ts";
import { Sidebar } from "./components/layout/Sidebar.tsx";
import { Header } from "./components/layout/Header.tsx";
import { DashboardPage } from "./pages/DashboardPage.tsx";
import { UsersPage } from "./pages/UsersPage.tsx";
import { WorkAllocationPage } from "./pages/WorkAllocationPage.tsx";
import { AttendancePage } from "./pages/AttendancePage.tsx";
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 [activePage, setActivePage] = useState<PageType>('dashboard');
const [activePage, setActivePage] = useState<PageType>("dashboard");
const { isAuthenticated, isLoading } = useAuth();
const renderPage = () => {
switch (activePage) {
case 'dashboard':
case "dashboard":
return <DashboardPage />;
case 'users':
case "users":
return <UsersPage />;
case 'allocation':
case "allocation":
return <WorkAllocationPage />;
case 'attendance':
case "attendance":
return <AttendancePage />;
case 'rates':
case "rates":
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:
return <DashboardPage />;
}
@@ -37,7 +67,8 @@ const AppContent: React.FC = () => {
return (
<div className="flex h-screen items-center justify-center bg-gray-100">
<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>
</div>
</div>
@@ -52,11 +83,14 @@ const AppContent: React.FC = () => {
// Show main app if authenticated
return (
<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">
<Header />
<main className="flex-1 overflow-y-auto">
{renderPage()}
</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 { Bell, LogOut, X, Camera, Shield, User, Mail, Building2, ChevronDown, ChevronUp } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { useDepartments } from '../../hooks/useDepartments';
import React, { useEffect, useState } from "react";
import {
Bell,
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 {
isOpen: boolean;
@@ -10,78 +27,100 @@ interface ProfilePopupProps {
}
// Permission definitions for each role
const rolePermissions: Record<string, { title: string; permissions: string[] }> = {
const rolePermissions: Record<
string,
{ title: string; permissions: string[] }
> = {
Supervisor: {
title: 'Supervisor Permissions',
title: "Supervisor Permissions",
permissions: [
'View and manage employees in your department',
'Create and manage work allocations',
'Set contractor rates for your department',
'View attendance records',
'Manage check-in/check-out for employees',
]
"View and manage employees in your department",
"Create and manage work allocations",
"Set contractor rates for your department",
"View attendance records",
"Manage check-in/check-out for employees",
],
},
Employee: {
title: 'Employee Permissions',
title: "Employee Permissions",
permissions: [
'View your work allocations',
'View your attendance records',
'Check-in and check-out',
'View assigned tasks',
]
"View your work allocations",
"View your attendance records",
"Check-in and check-out",
"View assigned tasks",
],
},
Contractor: {
title: 'Contractor Permissions',
title: "Contractor Permissions",
permissions: [
'View assigned work allocations',
'View your rate configurations',
'Track work completion status',
]
"View assigned work allocations",
"View your rate configurations",
"Track work completion status",
],
},
SuperAdmin: {
title: 'Super Admin Permissions',
title: "Super Admin Permissions",
permissions: [
'Full system access',
'Manage all users and departments',
'Configure all contractor rates',
'View all work allocations and reports',
'System configuration and settings',
]
}
"Full system access",
"Manage all users and departments",
"Configure all contractor rates",
"View all work allocations and reports",
"System configuration and settings",
],
},
};
const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }) => {
const ProfilePopup: React.FC<ProfilePopupProps> = (
{ isOpen, onClose, onLogout },
) => {
const { user } = useAuth();
const { departments } = useDepartments();
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;
const userDepartment = departments.find(d => d.id === user?.department_id);
const userPermissions = rolePermissions[user?.role || 'Employee'];
const userDepartment = departments.find((d) => d.id === user?.department_id);
const userPermissions = rolePermissions[user?.role || "Employee"];
const isEmployeeOrContractor = user?.role === "Employee" ||
user?.role === "Contractor";
const isContractor = user?.role === "Contractor";
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 */}
<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-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} />
</button>
</div>
<div className="flex flex-col items-center -mt-2">
<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">
{user?.name?.charAt(0).toUpperCase() || 'U'}
{user?.name?.charAt(0).toUpperCase() || "U"}
</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">
<Camera size={12} className="text-white" />
</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">
{user?.role || 'User'}
{user?.role || "User"}
</span>
</div>
</div>
@@ -94,7 +133,9 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
</div>
<div>
<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>
@@ -104,24 +145,139 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
</div>
<div className="flex-1 min-w-0">
<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>
{user?.role !== 'SuperAdmin' && userDepartment && (
{user?.role !== "SuperAdmin" && userDepartment && (
<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">
<Building2 size={18} className="text-green-600" />
</div>
<div>
<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>
)}
{/* 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 */}
<button
<button type="button"
onClick={() => setShowPermissions(!showPermissions)}
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" />
</div>
<div className="text-left">
<p className="text-xs text-gray-500 font-medium">Your Permissions</p>
<p className="text-sm font-semibold text-gray-800">View what you can do</p>
<p className="text-xs text-gray-500 font-medium">
Your Permissions
</p>
<p className="text-sm font-semibold text-gray-800">
View what you can do
</p>
</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>
{showPermissions && userPermissions && (
<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">
{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>
{perm}
</li>
@@ -154,7 +321,7 @@ const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }
{/* Sign Out Button */}
<div className="px-6 pb-4">
<button
<button type="button"
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"
>
@@ -179,18 +346,21 @@ export const Header: React.FC = () => {
<header className="bg-white border-b border-gray-200 px-6 py-4 relative">
<div className="flex items-center justify-between">
<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 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} />
<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 type="button"
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"
>
{user?.name?.charAt(0).toUpperCase() || 'U'}
{user?.name?.charAt(0).toUpperCase() || "U"}
</button>
</div>
</div>

View File

@@ -1,6 +1,19 @@
import React from 'react';
import { LayoutDashboard, Users, Briefcase, CalendarCheck, DollarSign, ClipboardList } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import React from "react";
import {
ArrowRightLeft,
Briefcase,
CalendarCheck,
ClipboardList,
CreditCard,
DollarSign,
Eye,
FileSpreadsheet,
Layers,
LayoutDashboard,
Scale,
Users,
} from "lucide-react";
import { useAuth } from "../../contexts/authContext.ts";
interface SidebarItemProps {
icon: React.ElementType;
@@ -9,13 +22,15 @@ interface SidebarItemProps {
onClick: () => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ icon: Icon, label, active, onClick }) => (
<button
const SidebarItem: React.FC<SidebarItemProps> = (
{ icon: Icon, label, active, onClick },
) => (
<button type="button"
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 ${
active
? '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'
? "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"
}`}
>
<Icon size={20} />
@@ -30,7 +45,16 @@ interface SidebarProps {
export const Sidebar: React.FC<SidebarProps> = ({ activePage, onNavigate }) => {
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 (
<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" />
</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>
</div>
</div>
</div>
<nav className="flex-1 py-4">
{/* Dashboard - visible to all */}
<SidebarItem
icon={LayoutDashboard}
label="Dashboard"
active={activePage === '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')}
active={activePage === "dashboard"}
onClick={() => onNavigate("dashboard")}
/>
{/* 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 && (
<SidebarItem
icon={DollarSign}
label="Contractor Rates"
active={activePage === 'rates'}
onClick={() => onNavigate('rates')}
active={activePage === "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>
{/* 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>
);
};

View File

@@ -1,40 +1,45 @@
import React, { ReactNode, ButtonHTMLAttributes } from 'react';
import React, { ButtonHTMLAttributes, ReactNode } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
fullWidth?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
variant = "primary",
size = "md",
fullWidth = false,
className = '',
className = "",
...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 = {
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',
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',
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",
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 = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
const widthStyle = fullWidth ? 'w-full' : '';
const widthStyle = fullWidth ? "w-full" : "";
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${widthStyle} ${className}`}
className={`${baseStyles} ${variantStyles[variant]} ${
sizeStyles[size]
} ${widthStyle} ${className}`}
{...props}
>
{children}

View File

@@ -1,11 +1,11 @@
import React, { ReactNode } from 'react';
import React, { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
}
export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
export const Card: React.FC<CardProps> = ({ children, className = "" }) => {
return (
<div className={`bg-white rounded-lg shadow-sm ${className}`}>
{children}
@@ -19,9 +19,13 @@ interface CardHeaderProps {
className?: string;
}
export const CardHeader: React.FC<CardHeaderProps> = ({ title, action, className = '' }) => {
export const CardHeader: React.FC<CardHeaderProps> = (
{ title, action, className = "" },
) => {
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>
{action && <div>{action}</div>}
</div>
@@ -33,6 +37,8 @@ interface CardContentProps {
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>;
};

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> {
label?: string;
@@ -6,7 +7,9 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
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 (
<div className="w-full">
{label && (
@@ -16,8 +19,10 @@ export const Input: React.FC<InputProps> = ({ label, error, required, className
)}
<input
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' : ''
} ${disabled ? 'bg-gray-100 text-gray-600 cursor-not-allowed' : ''} ${className}`}
error ? "border-red-500" : ""
} ${
disabled ? "bg-gray-100 text-gray-600 cursor-not-allowed" : ""
} ${className}`}
disabled={disabled}
{...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> {
label?: string;
error?: string;
@@ -33,7 +82,9 @@ interface SelectProps extends InputHTMLAttributes<HTMLSelectElement> {
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 (
<div className="w-full">
{label && (
@@ -43,8 +94,10 @@ export const Select: React.FC<SelectProps> = ({ label, error, required, options,
)}
<select
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' : ''
} ${disabled ? 'bg-gray-100 text-gray-600 cursor-not-allowed' : ''} ${className}`}
error ? "border-red-500" : ""
} ${
disabled ? "bg-gray-100 text-gray-600 cursor-not-allowed" : ""
} ${className}`}
disabled={disabled}
{...props}
>
@@ -66,7 +119,9 @@ interface TextAreaProps extends InputHTMLAttributes<HTMLTextAreaElement> {
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 (
<div className="w-full">
{label && (
@@ -77,7 +132,7 @@ export const TextArea: React.FC<TextAreaProps> = ({ label, error, required, rows
<textarea
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 ${
error ? 'border-red-500' : ''
error ? "border-red-500" : ""
} ${className}`}
{...props}
/>

View File

@@ -1,11 +1,11 @@
import React, { ReactNode } from 'react';
import React, { ReactNode } from "react";
interface TableProps {
children: ReactNode;
className?: string;
}
export const Table: React.FC<TableProps> = ({ children, className = '' }) => {
export const Table: React.FC<TableProps> = ({ children, className = "" }) => {
return (
<div className="overflow-x-auto">
<table className={`w-full ${className}`}>{children}</table>
@@ -39,11 +39,15 @@ interface TableRowProps {
className?: string;
}
export const TableRow: React.FC<TableRowProps> = ({ children, onClick, className = '' }) => {
export const TableRow: React.FC<TableRowProps> = (
{ children, onClick, className = "" },
) => {
return (
<tr
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}
</tr>
@@ -55,9 +59,13 @@ interface TableHeadProps {
className?: string;
}
export const TableHead: React.FC<TableHeadProps> = ({ children, className = '' }) => {
export const TableHead: React.FC<TableHeadProps> = (
{ children, className = "" },
) => {
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}
</th>
);
@@ -68,6 +76,12 @@ interface TableCellProps {
className?: string;
}
export const TableCell: React.FC<TableCellProps> = ({ children, className = '' }) => {
return <td className={`py-3 px-4 text-sm text-gray-700 ${className}`}>{children}</td>;
export const TableCell: React.FC<TableCellProps> = (
{ 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 { api } from '../services/api';
import type { User } from '../types';
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;
};
import React, { ReactNode, useState } from "react";
import { api } from "../services/api.ts";
import type { User } from "../types.ts";
import { AuthContext } from "./authContext.ts";
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Helper to get initial user from localStorage
const getInitialUser = (): User | null => {
const token = localStorage.getItem("token");
const storedUser = localStorage.getItem("user");
useEffect(() => {
// Check for existing session
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
setUser(parsedUser);
} catch (error) {
console.error('Failed to parse stored user:', error);
localStorage.removeItem('user');
localStorage.removeItem('token');
}
if (token && storedUser) {
try {
return JSON.parse(storedUser);
} 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) => {
try {
const response = await api.login(username, password);
// Store token and user
localStorage.setItem('token', response.token);
localStorage.setItem('user', JSON.stringify(response.user));
localStorage.setItem("token", response.token);
localStorage.setItem("user", JSON.stringify(response.user));
setUser(response.user);
} catch (error) {
console.error('Login failed:', error);
console.error("Login failed:", error);
throw error;
}
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem("token");
localStorage.removeItem("user");
setUser(null);
};
const updateUser = (updatedUser: User) => {
setUser(updatedUser);
localStorage.setItem('user', JSON.stringify(updatedUser));
localStorage.setItem("user", JSON.stringify(updatedUser));
};
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 { api } from '../services/api';
import type { Department, SubDepartment } from '../types';
import { useCallback, useEffect, useState } from "react";
import { api } from "../services/api.ts";
import type { Department, SubDepartment } from "../types.ts";
export const useDepartments = () => {
const [departments, setDepartments] = useState<Department[]>([]);
@@ -13,8 +13,8 @@ export const useDepartments = () => {
try {
const data = await api.getDepartments();
setDepartments(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch departments');
} catch (err: never) {
setError(err.message || "Failed to fetch departments");
} finally {
setLoading(false);
}
@@ -37,7 +37,7 @@ export const useSubDepartments = (departmentId?: string) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchSubDepartments = async () => {
const fetchSubDepartments = useCallback(async () => {
if (!departmentId) {
setSubDepartments([]);
return;
@@ -48,16 +48,16 @@ export const useSubDepartments = (departmentId?: string) => {
try {
const data = await api.getSubDepartments(parseInt(departmentId));
setSubDepartments(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch subdepartments');
} catch (err: never) {
setError(err.message || "Failed to fetch subdepartments");
} finally {
setLoading(false);
}
};
}, [departmentId]);
useEffect(() => {
fetchSubDepartments();
}, [departmentId]);
}, [fetchSubDepartments]);
return {
subDepartments,

View File

@@ -1,56 +1,61 @@
import { useState, useEffect } from 'react';
import { api } from '../services/api';
import type { User } from '../types';
import { useCallback, useEffect, useState } from "react";
import { api } from "../services/api";
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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchEmployees = async () => {
const fetchEmployees = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getUsers(filters);
setEmployees(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch employees');
console.error('Failed to fetch employees:', err);
} catch (err: never) {
setError(err.message || "Failed to fetch employees");
console.error("Failed to fetch employees:", err);
} finally {
setLoading(false);
}
};
}, [filters]);
useEffect(() => {
fetchEmployees();
}, [JSON.stringify(filters)]);
}, [fetchEmployees]);
const createEmployee = async (data: any) => {
const createEmployee = async (data: Omit<User, "id">) => {
setLoading(true);
setError(null);
try {
const newEmployee = await api.createUser(data);
await fetchEmployees(); // Refresh list
return newEmployee;
} catch (err: any) {
setError(err.message || 'Failed to create employee');
console.error('Failed to create employee:', err);
} catch (err: never) {
setError(err.message || "Failed to create employee");
console.error("Failed to create employee:", err);
throw err;
} finally {
setLoading(false);
}
};
const updateEmployee = async (id: number, data: any) => {
const updateEmployee = async (
id: number,
data: Partial<Omit<User, "id">>,
) => {
setLoading(true);
setError(null);
try {
const updated = await api.updateUser(id, data);
await fetchEmployees(); // Refresh list
return updated;
} catch (err: any) {
setError(err.message || 'Failed to update employee');
console.error('Failed to update employee:', err);
} catch (err: never) {
setError(err.message || "Failed to update employee");
console.error("Failed to update employee:", err);
throw err;
} finally {
setLoading(false);
@@ -63,9 +68,9 @@ export const useEmployees = (filters?: { role?: string; departmentId?: number })
try {
await api.deleteUser(id);
await fetchEmployees(); // Refresh list
} catch (err: any) {
setError(err.message || 'Failed to delete employee');
console.error('Failed to delete employee:', err);
} catch (err: never) {
setError(err.message || "Failed to delete employee");
console.error("Failed to delete employee:", err);
throw err;
} finally {
setLoading(false);

View File

@@ -1,56 +1,66 @@
import { useState, useEffect } from 'react';
import { api } from '../services/api';
import type { WorkAllocation } from '../types';
import { useCallback, useEffect, useState } from "react";
import { api } from "../services/api.ts";
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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAllocations = async () => {
const fetchAllocations = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getWorkAllocations(filters);
setAllocations(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch work allocations');
console.error('Failed to fetch work allocations:', err);
} catch (err: never) {
setError(err.message || "Failed to fetch work allocations");
console.error("Failed to fetch work allocations:", err);
} finally {
setLoading(false);
}
};
}, [filters]);
useEffect(() => {
fetchAllocations();
}, [JSON.stringify(filters)]);
}, [fetchAllocations]);
const createAllocation = async (data: any) => {
const createAllocation = async (data: Omit<WorkAllocation, "id">) => {
setLoading(true);
setError(null);
try {
const newAllocation = await api.createWorkAllocation(data);
await fetchAllocations(); // Refresh list
return newAllocation;
} catch (err: any) {
setError(err.message || 'Failed to create work allocation');
console.error('Failed to create work allocation:', err);
} catch (err: never) {
setError(err.message || "Failed to create work allocation");
console.error("Failed to create work allocation:", err);
throw err;
} finally {
setLoading(false);
}
};
const updateAllocation = async (id: number, status: string, completionDate?: string) => {
const updateAllocation = async (
id: number,
status: string,
completionDate?: string,
) => {
setLoading(true);
setError(null);
try {
const updated = await api.updateWorkAllocationStatus(id, status, completionDate);
const updated = await api.updateWorkAllocationStatus(
id,
status,
completionDate,
);
await fetchAllocations(); // Refresh list
return updated;
} catch (err: any) {
setError(err.message || 'Failed to update work allocation');
console.error('Failed to update work allocation:', err);
} catch (err: never) {
setError(err.message || "Failed to update work allocation");
console.error("Failed to update work allocation:", err);
throw err;
} finally {
setLoading(false);
@@ -63,9 +73,9 @@ export const useWorkAllocations = (filters?: { employeeId?: number; status?: str
try {
await api.deleteWorkAllocation(id);
await fetchAllocations(); // Refresh list
} catch (err: any) {
setError(err.message || 'Failed to delete work allocation');
console.error('Failed to delete work allocation:', err);
} catch (err: never) {
setError(err.message || "Failed to delete work allocation");
console.error("Failed to delete work allocation:", err);
throw err;
} finally {
setLoading(false);

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</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 { AlertTriangle, CheckCircle, Clock, RefreshCw, LogIn, LogOut, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { Card, CardContent } from '../components/ui/Card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Select, Input } from '../components/ui/Input';
import { api } from '../services/api';
import { useEmployees } from '../hooks/useEmployees';
import React, { useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
CheckCircle,
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 = () => {
const [activeTab, setActiveTab] = useState<'records' | 'checkin'>('records');
const [activeTab, setActiveTab] = useState<"records" | "checkin">("records");
const [attendance, setAttendance] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const { employees } = useEmployees();
// Check-in form state
const [selectedEmployee, setSelectedEmployee] = useState('');
const [workDate, setWorkDate] = useState(new Date().toISOString().split('T')[0]);
const [selectedEmployee, setSelectedEmployee] = useState("");
const [workDate, setWorkDate] = useState(
new Date().toISOString().split("T")[0],
);
const [checkInLoading, setCheckInLoading] = useState(false);
const [employeeStatus, setEmployeeStatus] = useState<any>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<'date' | 'employee' | 'status'>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<"date" | "employee" | "status">(
"date",
);
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
const fetchAttendance = async () => {
setLoading(true);
setError('');
setError("");
try {
const data = await api.getAttendance();
setAttendance(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch attendance');
setError(err.message || "Failed to fetch attendance");
} finally {
setLoading(false);
}
@@ -41,31 +71,17 @@ export const AttendancePage: React.FC = () => {
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 () => {
if (!selectedEmployee) {
alert('Please select an employee');
alert("Please select an employee/contractor");
return;
}
setCheckInLoading(true);
try {
await api.checkIn(parseInt(selectedEmployee), workDate);
await fetchAttendance();
setEmployeeStatus({ status: 'CheckedIn' });
} catch (err: any) {
alert(err.message || 'Failed to check in');
alert(err.message || "Failed to check in");
} finally {
setCheckInLoading(false);
}
@@ -73,72 +89,137 @@ export const AttendancePage: React.FC = () => {
const handleCheckOut = async () => {
if (!selectedEmployee) {
alert('Please select an employee');
alert("Please select an employee/contractor");
return;
}
setCheckInLoading(true);
try {
await api.checkOut(parseInt(selectedEmployee), workDate);
await fetchAttendance();
setEmployeeStatus({ status: 'CheckedOut' });
} catch (err: any) {
alert(err.message || 'Failed to check out');
alert(err.message || "Failed to check out");
} finally {
setCheckInLoading(false);
}
};
const employeeOptions = [
{ value: '', label: 'Select Employee' },
...employees.filter(e => e.role === 'Employee').map(e => ({
value: String(e.id),
label: `${e.name} (${e.username})`
}))
const handleMarkAbsent = async () => {
if (!selectedEmployee) {
alert("Please select an employee/contractor");
return;
}
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
const filteredAndSortedAttendance = useMemo(() => {
let filtered = attendance;
// Apply search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(record =>
filtered = filtered.filter((record) =>
record.employee_name?.toLowerCase().includes(query) ||
record.status?.toLowerCase().includes(query)
);
}
// Apply sorting
return [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'date':
comparison = new Date(a.work_date).getTime() - new Date(b.work_date).getTime();
case "date":
comparison = new Date(a.work_date).getTime() -
new Date(b.work_date).getTime();
break;
case 'employee':
comparison = (a.employee_name || '').localeCompare(b.employee_name || '');
case "employee":
comparison = (a.employee_name || "").localeCompare(
b.employee_name || "",
);
break;
case 'status':
comparison = (a.status || '').localeCompare(b.status || '');
case "status":
comparison = (a.status || "").localeCompare(b.status || "");
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
return sortDirection === "asc" ? comparison : -comparison;
});
}, [attendance, searchQuery, sortField, sortDirection]);
const handleSort = (field: 'date' | 'employee' | 'status') => {
const handleSort = (field: "date" | "employee" | "status") => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
setSortDirection((prev) => prev === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection('asc');
setSortDirection("asc");
}
};
const SortIcon = ({ field }: { field: 'date' | 'employee' | 'status' }) => {
if (sortField !== field) return <ArrowUpDown size={14} className="ml-1 text-gray-400" />;
return sortDirection === 'asc'
const SortIcon = ({ field }: { field: "date" | "employee" | "status" }) => {
if (sortField !== field) {
return <ArrowUpDown size={14} className="ml-1 text-gray-400" />;
}
return sortDirection === "asc"
? <ArrowUp 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="flex space-x-8 px-6">
<button
onClick={() => setActiveTab('records')}
onClick={() => setActiveTab("records")}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === 'records'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
activeTab === "records"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Attendance Records
</button>
<button
onClick={() => setActiveTab('checkin')}
onClick={() => setActiveTab("checkin")}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === 'checkin'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
activeTab === "checkin"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Check In/Out
@@ -172,11 +253,14 @@ export const AttendancePage: React.FC = () => {
</div>
<CardContent>
{activeTab === 'records' && (
{activeTab === "records" && (
<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} />
<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 name or status..."
@@ -190,7 +274,7 @@ export const AttendancePage: React.FC = () => {
Refresh
</Button>
</div>
<div className="mb-4 text-sm text-gray-600">
Total Records: {filteredAndSortedAttendance.length}
</div>
@@ -201,89 +285,194 @@ export const AttendancePage: React.FC = () => {
</div>
)}
{loading ? (
<div className="text-center py-8">Loading attendance records...</div>
) : filteredAndSortedAttendance.length > 0 ? (
<Table>
<TableHeader>
<TableHead>ID</TableHead>
<TableHead>
<button
onClick={() => handleSort('employee')}
className="flex items-center hover:text-blue-600 transition-colors"
>
Employee <SortIcon field="employee" />
</button>
</TableHead>
<TableHead>
<button
onClick={() => handleSort('date')}
className="flex items-center hover:text-blue-600 transition-colors"
>
Date <SortIcon field="date" />
</button>
</TableHead>
<TableHead>Check In</TableHead>
<TableHead>Check Out</TableHead>
<TableHead>
<button
onClick={() => handleSort('status')}
className="flex items-center hover:text-blue-600 transition-colors"
>
Status <SortIcon field="status" />
</button>
</TableHead>
</TableHeader>
<TableBody>
{filteredAndSortedAttendance.map((record) => (
<TableRow key={record.id}>
<TableCell>{record.id}</TableCell>
<TableCell>{record.employee_name || '-'}</TableCell>
<TableCell>{new Date(record.work_date).toLocaleDateString()}</TableCell>
<TableCell>
{record.check_in_time
? new Date(record.check_in_time).toLocaleTimeString()
: '-'}
</TableCell>
<TableCell>
{record.check_out_time
? new Date(record.check_out_time).toLocaleTimeString()
: '-'}
</TableCell>
<TableCell>
<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' :
'bg-gray-100 text-gray-700'
}`}>
{record.status === 'CheckedOut' ? 'Completed' :
record.status === 'CheckedIn' ? 'Checked In' : record.status}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-gray-500">
{searchQuery ? 'No matching records found' : 'No attendance records found'}
</div>
)}
{loading
? (
<div className="text-center py-8">
Loading attendance records...
</div>
)
: filteredAndSortedAttendance.length > 0
? (
<Table>
<TableHeader>
<TableHead>ID</TableHead>
<TableHead>
<button
onClick={() => handleSort("employee")}
className="flex items-center hover:text-blue-600 transition-colors"
>
Employee <SortIcon field="employee" />
</button>
</TableHead>
<TableHead>
<button
onClick={() => handleSort("date")}
className="flex items-center hover:text-blue-600 transition-colors"
>
Date <SortIcon field="date" />
</button>
</TableHead>
<TableHead>Check In</TableHead>
<TableHead>Check Out</TableHead>
<TableHead>
<button
onClick={() => handleSort("status")}
className="flex items-center hover:text-blue-600 transition-colors"
>
Status <SortIcon field="status" />
</button>
</TableHead>
<TableHead>Remark</TableHead>
{canEditAttendance && <TableHead>Actions</TableHead>}
</TableHeader>
<TableBody>
{filteredAndSortedAttendance.map((record) => (
<TableRow key={record.id}>
<TableCell>{record.id}</TableCell>
<TableCell>{record.employee_name || "-"}</TableCell>
<TableCell>
{new Date(record.work_date).toLocaleDateString()}
</TableCell>
<TableCell>
{record.check_in_time
? new Date(record.check_in_time)
.toLocaleTimeString()
: "-"}
</TableCell>
<TableCell>
{record.check_out_time
? new Date(record.check_out_time)
.toLocaleTimeString()
: "-"}
</TableCell>
<TableCell>
{editingRecord === record.id
? (
<select
value={editStatus}
onChange={(e) =>
setEditStatus(
e.target.value as AttendanceStatus,
)}
className="px-2 py-1 border border-gray-300 rounded text-sm"
>
<option value="CheckedIn">Checked In</option>
<option value="CheckedOut">
Checked Out
</option>
<option value="Absent">Absent</option>
<option value="HalfDay">Half Day</option>
<option value="Late">Late</option>
</select>
)
: (
<span
className={`px-2 py-1 rounded text-xs font-medium ${
record.status === "CheckedOut"
? "bg-green-100 text-green-700"
: record.status === "CheckedIn"
? "bg-blue-100 text-blue-700"
: record.status === "Absent"
? "bg-red-100 text-red-700"
: record.status === "HalfDay"
? "bg-orange-100 text-orange-700"
: record.status === "Late"
? "bg-yellow-100 text-yellow-700"
: "bg-gray-100 text-gray-700"
}`}
>
{record.status === "CheckedOut"
? "Completed"
: record.status === "CheckedIn"
? "Checked In"
: record.status === "HalfDay"
? "Half Day"
: record.status}
</span>
)}
</TableCell>
<TableCell>
{editingRecord === record.id
? (
<input
type="text"
value={editRemark}
onChange={(e) =>
setEditRemark(e.target.value)}
placeholder="Add remark..."
className="px-2 py-1 border border-gray-300 rounded text-sm w-32"
/>
)
: (
<span className="text-gray-500 text-sm">
{record.remark || "-"}
</span>
)}
</TableCell>
{canEditAttendance && (
<TableCell>
{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>
)}
{activeTab === 'checkin' && (
{activeTab === "checkin" && (
<div className="max-w-2xl">
<h3 className="text-lg font-semibold text-gray-800 mb-2">Check In / Check Out Management</h3>
<p className="text-sm text-gray-600 mb-6">Manage employee attendance</p>
<h3 className="text-lg font-semibold text-gray-800 mb-2">
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="grid grid-cols-2 gap-6">
<Select
label="Select Employee"
label="Select Employee/Contractor"
value={selectedEmployee}
onChange={(e) => setSelectedEmployee(e.target.value)}
options={employeeOptions}
options={attendanceUserOptions}
/>
<Input
label="Work Date"
@@ -294,57 +483,120 @@ export const AttendancePage: React.FC = () => {
</div>
{selectedEmployee && (
<div className={`border rounded-md p-4 flex items-start ${
employeeStatus?.status === 'CheckedIn'
? 'bg-blue-50 border-blue-200'
: employeeStatus?.status === 'CheckedOut'
? 'bg-green-50 border-green-200'
: 'bg-yellow-50 border-yellow-200'
}`}>
{employeeStatus?.status === 'CheckedIn' ? (
<>
<Clock size={20} className="text-blue-600 mr-2 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-800">
Employee is currently checked in. Check-in time: {
employeeStatus.check_in_time
? new Date(employeeStatus.check_in_time).toLocaleTimeString()
: 'N/A'
}
</p>
</>
) : employeeStatus?.status === 'CheckedOut' ? (
<>
<CheckCircle size={20} className="text-green-600 mr-2 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-800">
Employee has completed attendance for this date.
</p>
</>
) : (
<>
<AlertTriangle size={20} className="text-yellow-600 mr-2 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-800">Employee has not checked in for this date</p>
</>
<div className="space-y-3">
{/* Current Status */}
<div
className={`border rounded-md p-4 flex items-start ${
hasActiveCheckIn
? "bg-blue-50 border-blue-200"
: userDayRecords.length > 0
? "bg-green-50 border-green-200"
: "bg-yellow-50 border-yellow-200"
}`}
>
{hasActiveCheckIn ? (
<>
<Clock
size={20}
className="text-blue-600 mr-2 flex-shrink-0 mt-0.5"
/>
<p className="text-sm text-blue-800">
Currently checked in. Please check out before checking in again.
</p>
</>
) : userDayRecords.length > 0 ? (
<>
<CheckCircle
size={20}
className="text-green-600 mr-2 flex-shrink-0 mt-0.5"
/>
<p className="text-sm text-green-800">
{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 className="flex justify-center gap-4 pt-4">
<Button
size="lg"
<Button
size="lg"
onClick={handleCheckIn}
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status === 'CheckedIn' || employeeStatus?.status === 'CheckedOut'}
disabled={checkInLoading || !selectedEmployee || hasActiveCheckIn}
>
<LogIn size={16} className="mr-2" />
{checkInLoading ? 'Processing...' : 'Check In'}
{checkInLoading ? "Processing..." : "Check In"}
</Button>
<Button
size="lg"
variant="outline"
<Button
size="lg"
variant="secondary"
onClick={handleCheckOut}
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status !== 'CheckedIn'}
disabled={checkInLoading || !selectedEmployee || !hasActiveCheckIn}
>
<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>
</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 { useAuth } from '../contexts/AuthContext';
import {
Users, Lock, Eye, EyeOff, XCircle, Mail, ArrowRight,
CheckCircle, X, Sparkles, Shield, KeyRound
} from 'lucide-react';
import React, { useEffect, useState } from "react";
import { useAuth } from "../contexts/authContext.ts";
import {
ArrowRight,
CheckCircle,
Eye,
EyeOff,
KeyRound,
Lock,
Mail,
Shield,
Sparkles,
Users,
X,
XCircle,
} from "lucide-react";
export const LoginPage: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const [showError, setShowError] = useState(false);
const [loading, setLoading] = useState(false);
const { login } = useAuth();
// Forgot password modal state
const [showForgotModal, setShowForgotModal] = useState(false);
const [forgotEmail, setForgotEmail] = useState('');
const [forgotEmail, setForgotEmail] = useState("");
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotSuccess, setForgotSuccess] = useState(false);
const [forgotError, setForgotError] = useState('');
const [forgotError, setForgotError] = useState("");
// Auto-hide error after 5 seconds
useEffect(() => {
if (error) {
setShowError(true);
const timer = setTimeout(() => {
setShowError(false);
setTimeout(() => setError(''), 300);
setTimeout(() => setError(""), 300);
}, 5000);
return () => clearTimeout(timer);
}
@@ -36,18 +44,20 @@ export const LoginPage: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setError("");
setLoading(true);
try {
await login(username, password);
} catch (err: unknown) {
const error = err as Error;
const errorMessage = error.message?.includes('401') || error.message?.includes('Unauthorized') || error.message?.includes('Invalid')
? 'Invalid username or password'
: error.message || 'Login failed. Please check your credentials.';
const errorMessage = error.message?.includes("401") ||
error.message?.includes("Unauthorized") ||
error.message?.includes("Invalid")
? "Invalid username or password"
: error.message || "Login failed. Please check your credentials.";
setError(errorMessage);
console.error('Login error:', err);
console.error("Login error:", err);
} finally {
setLoading(false);
}
@@ -56,15 +66,15 @@ export const LoginPage: React.FC = () => {
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
setForgotLoading(true);
setForgotError('');
setForgotError("");
// Simulate API call (replace with actual API call)
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);
setForgotSuccess(true);
} catch {
setForgotError('Failed to send reset email. Please try again.');
setForgotError("Failed to send reset email. Please try again.");
} finally {
setForgotLoading(false);
}
@@ -72,9 +82,9 @@ export const LoginPage: React.FC = () => {
const closeForgotModal = () => {
setShowForgotModal(false);
setForgotEmail('');
setForgotEmail("");
setForgotSuccess(false);
setForgotError('');
setForgotError("");
};
return (
@@ -109,10 +119,15 @@ export const LoginPage: React.FC = () => {
<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">
<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>
<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>
{/* Login Form */}
@@ -139,7 +154,7 @@ export const LoginPage: React.FC = () => {
<Lock size={20} />
</div>
<input
type={showPassword ? 'text' : 'password'}
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
@@ -165,7 +180,9 @@ export const LoginPage: React.FC = () => {
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"
/>
<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>
<button
type="button"
@@ -182,17 +199,22 @@ export const LoginPage: React.FC = () => {
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"
>
{loading ? (
<>
<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" />
</>
)}
{loading
? (
<>
<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"
/>
</>
)}
</button>
</form>
@@ -202,16 +224,11 @@ export const LoginPage: React.FC = () => {
<div className="w-full border-t border-white/10" />
</div>
<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>
{/* Footer Info */}
<div className="text-center">
<p className="text-blue-200/40 text-xs">
Secure login powered by JWT authentication
</p>
</div>
</div>
{/* Version badge */}
@@ -222,7 +239,11 @@ export const LoginPage: React.FC = () => {
{/* Error Toast */}
{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="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0">
<XCircle size={24} />
@@ -239,106 +260,120 @@ export const LoginPage: React.FC = () => {
{showForgotModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={closeForgotModal}
/>
{/* 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">
{/* Close button */}
<button
<button type="button"
onClick={closeForgotModal}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<X size={24} />
</button>
{!forgotSuccess ? (
<>
{/* Header */}
<div className="text-center mb-6">
<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">
<KeyRound size={32} className="text-white" />
</div>
<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} />
{!forgotSuccess
? (
<>
{/* Header */}
<div className="text-center mb-6">
<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">
<KeyRound size={32} className="text-white" />
</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"
/>
<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>
{forgotError && (
<p className="text-red-400 text-sm text-center">{forgotError}</p>
)}
{/* 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>
<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
type="submit"
disabled={forgotLoading || !forgotEmail}
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"
>
{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
</>
{forgotError && (
<p className="text-red-400 text-sm text-center">
{forgotError}
</p>
)}
</button>
</form>
{/* Back to login */}
<button
onClick={closeForgotModal}
className="w-full mt-4 text-gray-400 hover:text-white text-sm transition-colors"
>
← Back to login
</button>
</>
) : (
/* Success State */
<div className="text-center py-4">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full shadow-lg mb-4">
<CheckCircle size={32} className="text-white" />
<button
type="submit"
disabled={forgotLoading || !forgotEmail}
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"
>
{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 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>
<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>
)}
{/* CSS for floating animation */}
<style>{`
<style>
{`
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); opacity: 0.2; }
50% { transform: translateY(-20px) rotate(180deg); opacity: 0.5; }
@@ -346,7 +381,8 @@ export const LoginPage: React.FC = () => {
.animate-float {
animation: float linear infinite;
}
`}</style>
`}
</style>
</div>
);
};

View File

@@ -1,35 +1,43 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Plus, RefreshCw, Trash2, Edit, DollarSign, Search } from 'lucide-react';
import { Card, CardHeader, CardContent } from '../components/ui/Card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Input, Select } from '../components/ui/Input';
import { api } from '../services/api';
import { useDepartments, useSubDepartments } from '../hooks/useDepartments';
import { useAuth } from '../contexts/AuthContext';
import React, { useEffect, useMemo, useState } from "react";
import { DollarSign, Edit, RefreshCw, 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 RatesPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'list' | 'add'>('list');
const [activeTab, setActiveTab] = useState<"list" | "add">("list");
const { user } = useAuth();
const { departments } = useDepartments();
const [rates, setRates] = useState<any[]>([]);
const [contractors, setContractors] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
// Form state
const [formData, setFormData] = useState({
contractorId: '',
subDepartmentId: '',
activity: '',
rate: '',
effectiveDate: new Date().toISOString().split('T')[0],
contractorId: "",
subDepartmentId: "",
activity: "",
rate: "",
effectiveDate: new Date().toISOString().split("T")[0],
});
const [selectedDept, setSelectedDept] = useState('');
const [selectedDept, setSelectedDept] = useState("");
const { subDepartments } = useSubDepartments(selectedDept);
const [formError, setFormError] = useState('');
const { activities } = useActivities(formData.subDepartmentId);
const [formError, setFormError] = useState("");
const [formLoading, setFormLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
// Edit mode
const [editingId, setEditingId] = useState<number | null>(null);
@@ -37,12 +45,12 @@ export const RatesPage: React.FC = () => {
// Fetch rates
const fetchRates = async () => {
setLoading(true);
setError('');
setError("");
try {
const data = await api.getContractorRates();
setRates(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch rates');
setError(err.message || "Failed to fetch rates");
} finally {
setLoading(false);
}
@@ -51,10 +59,10 @@ export const RatesPage: React.FC = () => {
// Fetch contractors
const fetchContractors = async () => {
try {
const data = await api.getUsers({ role: 'Contractor' });
const data = await api.getUsers({ role: "Contractor" });
setContractors(data);
} 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
useEffect(() => {
if (user?.role === 'Supervisor' && user?.department_id) {
if (user?.role === "Supervisor" && user?.department_id) {
setSelectedDept(String(user.department_id));
}
}, [user]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
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 = () => {
setFormData({
contractorId: '',
subDepartmentId: '',
activity: '',
rate: '',
effectiveDate: new Date().toISOString().split('T')[0],
contractorId: "",
subDepartmentId: "",
activity: "",
rate: "",
effectiveDate: new Date().toISOString().split("T")[0],
});
setEditingId(null);
setFormError('');
setFormError("");
};
const handleSubmit = async () => {
if (!formData.contractorId || !formData.rate || !formData.effectiveDate) {
setFormError('Contractor, rate, and effective date are required');
setFormError("Contractor, rate, and effective date are required");
return;
}
setFormLoading(true);
setFormError('');
setFormError("");
try {
if (editingId) {
@@ -107,7 +140,9 @@ export const RatesPage: React.FC = () => {
} else {
await api.setContractorRate({
contractorId: parseInt(formData.contractorId),
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : undefined,
subDepartmentId: formData.subDepartmentId
? parseInt(formData.subDepartmentId)
: undefined,
activity: formData.activity || undefined,
rate: parseFloat(formData.rate),
effectiveDate: formData.effectiveDate,
@@ -115,10 +150,10 @@ export const RatesPage: React.FC = () => {
}
resetForm();
setActiveTab('list');
setActiveTab("list");
fetchRates();
} catch (err: any) {
setFormError(err.message || 'Failed to save rate');
setFormError(err.message || "Failed to save rate");
} finally {
setFormLoading(false);
}
@@ -127,32 +162,36 @@ export const RatesPage: React.FC = () => {
const handleEdit = (rate: any) => {
setFormData({
contractorId: String(rate.contractor_id),
subDepartmentId: rate.sub_department_id ? String(rate.sub_department_id) : '',
activity: rate.activity || '',
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],
effectiveDate: rate.effective_date?.split("T")[0] ||
new Date().toISOString().split("T")[0],
});
setEditingId(rate.id);
setActiveTab('add');
setActiveTab("add");
};
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 {
await api.deleteContractorRate(id);
fetchRates();
} 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
const filteredRates = useMemo(() => {
if (!searchQuery) return rates;
const query = searchQuery.toLowerCase();
return rates.filter(rate =>
return rates.filter((rate) =>
rate.contractor_name?.toLowerCase().includes(query) ||
rate.sub_department_name?.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="flex space-x-8 px-6">
<button
onClick={() => { setActiveTab('list'); resetForm(); }}
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'
activeTab === "list"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Rate List
</button>
{canManageRates && (
<button
onClick={() => setActiveTab('add')}
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'
activeTab === "add"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{editingId ? 'Edit Rate' : 'Add Rate'}
{editingId ? "Edit Rate" : "Add Rate"}
</button>
)}
</div>
</div>
<CardContent>
{activeTab === 'list' && (
{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} />
<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, sub-department, activity..."
@@ -208,7 +253,7 @@ export const RatesPage: React.FC = () => {
Refresh
</Button>
</div>
<div className="mb-4 text-sm text-gray-600">
Total Rates: {filteredRates.length}
</div>
@@ -219,91 +264,113 @@ export const RatesPage: React.FC = () => {
</div>
)}
{loading ? (
<div className="text-center py-8">Loading rates...</div>
) : filteredRates.length > 0 ? (
<Table>
<TableHeader>
<TableHead>Contractor</TableHead>
<TableHead>Sub-Department</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Rate Type</TableHead>
<TableHead>Rate ()</TableHead>
<TableHead>Effective Date</TableHead>
{canManageRates && <TableHead>Actions</TableHead>}
</TableHeader>
<TableBody>
{filteredRates.map((rate) => (
<TableRow key={rate.id}>
<TableCell className="font-medium">{rate.contractor_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-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>
{loading
? <div className="text-center py-8">Loading rates...</div>
: filteredRates.length > 0
? (
<Table>
<TableHeader>
<TableHead>Contractor</TableHead>
<TableHead>Sub-Department</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Rate Type</TableHead>
<TableHead>Rate ()</TableHead>
<TableHead>Effective Date</TableHead>
{canManageRates && <TableHead>Actions</TableHead>}
</TableHeader>
<TableBody>
{filteredRates.map((rate) => (
<TableRow key={rate.id}>
<TableCell className="font-medium">
{rate.contractor_name}
</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>
)}
<TableCell>
{rate.sub_department_name || "-"}
</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-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>
)}
{activeTab === 'add' && canManageRates && (
{activeTab === "add" && canManageRates && (
<div className="max-w-2xl space-y-6">
<h3 className="text-lg font-semibold text-gray-800">
{editingId ? 'Edit Rate' : 'Add New Rate'}
{editingId ? "Edit Rate" : "Add New Rate"}
</h3>
<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">
<li><strong>Loading/Unloading:</strong> Total = Units × Rate per Unit</li>
<li><strong>Standard/Other:</strong> Total = Flat Rate (no unit calculation)</li>
<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>
</div>
@@ -322,27 +389,37 @@ export const RatesPage: React.FC = () => {
required
disabled={!!editingId}
options={[
{ value: '', label: 'Select Contractor' },
...contractors.map(c => ({ value: String(c.id), label: c.name }))
{ value: "", label: "Select Contractor" },
...contractors.map((c) => ({
value: String(c.id),
label: c.name,
})),
]}
/>
{user?.role === 'Supervisor' ? (
<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 }))
]}
/>
)}
{user?.role === "Supervisor"
? (
<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"
@@ -350,8 +427,11 @@ export const RatesPage: React.FC = () => {
onChange={handleInputChange}
disabled={!!editingId}
options={[
{ value: '', label: 'Select Sub-Department (Optional)' },
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
{ value: "", label: "Select Sub-Department (Optional)" },
...subDepartments.map((s) => ({
value: String(s.id),
label: s.name,
})),
]}
/>
<Select
@@ -359,18 +439,33 @@ export const RatesPage: React.FC = () => {
name="activity"
value={formData.activity}
onChange={handleInputChange}
disabled={!formData.subDepartmentId}
options={[
{ value: '', label: 'Select Activity (Optional)' },
{ value: 'Loading', label: 'Loading (per unit × rate)' },
{ value: 'Unloading', label: 'Unloading (per unit × rate)' },
{ value: 'Standard', label: 'Standard Work (flat rate)' },
{ value: 'Other', label: 'Other (flat rate)' },
{
value: "",
label: formData.subDepartmentId
? "Select Activity (Optional)"
: "Select Sub-Department First",
},
...activities.map((a) => ({
value: a.name,
label: `${a.name} (${
a.unit_of_measurement === "Per Bag"
? "per unit × rate"
: "flat rate"
})`,
})),
]}
/>
<Input
label={formData.activity === 'Loading' || formData.activity === 'Unloading'
? "Rate per Unit (₹)"
: "Standard Rate (₹)"}
label={(() => {
const selectedActivity = activities.find((a) =>
a.name === formData.activity
);
return selectedActivity?.unit_of_measurement === "Per Bag"
? "Rate per Unit (₹)"
: "Rate Amount (₹)";
})()}
name="rate"
type="number"
value={formData.rate}
@@ -389,14 +484,20 @@ export const RatesPage: React.FC = () => {
</div>
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => { setActiveTab('list'); resetForm(); }}>
<Button
variant="primary"
onClick={() => {
setActiveTab("list");
resetForm();
}}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={formLoading}>
{formLoading ? 'Saving...' : (
{formLoading ? "Saving..." : (
<>
<DollarSign size={16} className="mr-2" />
{editingId ? 'Update Rate' : 'Add Rate'}
{editingId ? "Update Rate" : "Add Rate"}
</>
)}
</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 { Plus, RefreshCw, CheckCircle, Trash2, Search } from 'lucide-react';
import { Card, CardContent } from '../components/ui/Card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Input, Select, TextArea } from '../components/ui/Input';
import { useWorkAllocations } from '../hooks/useWorkAllocations';
import { useDepartments, useSubDepartments } from '../hooks/useDepartments';
import { useEmployees } from '../hooks/useEmployees';
import { useAuth } from '../contexts/AuthContext';
import { api } from '../services/api';
import React, { useEffect, useState } from "react";
import { CheckCircle, Plus, RefreshCw, 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, 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 = () => {
const [activeTab, setActiveTab] = useState<'create' | 'view' | 'summary'>('view');
const [searchQuery, setSearchQuery] = useState('');
const { allocations, loading, error, refresh, createAllocation, updateAllocation, deleteAllocation } = useWorkAllocations();
const [activeTab, setActiveTab] = useState<"create" | "view" | "summary">(
"view",
);
const [searchQuery, setSearchQuery] = useState("");
const {
allocations,
loading,
error,
refresh,
createAllocation,
updateAllocation,
deleteAllocation,
} = useWorkAllocations();
const { departments } = useDepartments();
const { employees } = useEmployees();
const { user } = useAuth();
const [contractors, setContractors] = useState<any[]>([]);
// Check if user is supervisor (limited to their department)
const isSupervisor = user?.role === 'Supervisor';
const canCreateAllocation = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
const isSupervisor = user?.role === "Supervisor";
// 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
const [formData, setFormData] = useState({
employeeId: '',
contractorId: '',
subDepartmentId: '',
activity: '',
description: '',
assignedDate: new Date().toISOString().split('T')[0],
rateId: '',
departmentId: '',
units: '',
employeeId: "",
contractorId: "",
subDepartmentId: "",
activity: "",
description: "",
assignedDate: new Date().toISOString().split("T")[0],
rateId: "",
departmentId: "",
units: "",
});
const [selectedDept, setSelectedDept] = useState('');
const [selectedDept, setSelectedDept] = useState("");
const { subDepartments } = useSubDepartments(selectedDept);
const [formError, setFormError] = useState('');
const { activities } = useActivities(formData.subDepartmentId);
const [formError, setFormError] = useState("");
const [formLoading, setFormLoading] = useState(false);
const [contractorRates, setContractorRates] = useState<any[]>([]);
@@ -56,11 +75,17 @@ export const WorkAllocationPage: React.FC = () => {
}, [formData.contractorId]);
// Get selected rate details
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';
const selectedRate = contractorRates.find((r) =>
r.id === parseInt(formData.rateId)
);
// 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
const unitCount = parseFloat(formData.units) || 0;
const rateAmount = parseFloat(selectedRate?.rate) || 0;
@@ -71,40 +96,74 @@ export const WorkAllocationPage: React.FC = () => {
if (isSupervisor && user?.department_id) {
const deptId = String(user.department_id);
setSelectedDept(deptId);
setFormData(prev => ({ ...prev, departmentId: deptId }));
setFormData((prev) => ({ ...prev, departmentId: deptId }));
}
}, [isSupervisor, user?.department_id]);
// Load contractors
useEffect(() => {
api.getUsers({ role: 'Contractor' }).then(setContractors).catch(console.error);
api.getUsers({ role: "Contractor" }).then(setContractors).catch(
console.error,
);
}, []);
// Filter employees by selected contractor
const filteredEmployees = formData.contractorId
? employees.filter(e => e.contractor_id === parseInt(formData.contractorId))
: employees.filter(e => e.role === 'Employee');
const filteredEmployees = formData.contractorId
? employees.filter((e) =>
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;
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 () => {
if (!formData.employeeId || !formData.contractorId) {
setFormError('Please select employee and contractor');
setFormError("Please select employee and contractor");
return;
}
setFormLoading(true);
setFormError('');
setFormError("");
try {
await createAllocation({
employeeId: parseInt(formData.employeeId),
contractorId: parseInt(formData.contractorId),
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : null,
subDepartmentId: formData.subDepartmentId
? parseInt(formData.subDepartmentId)
: null,
activity: formData.activity || null,
description: formData.description,
assignedDate: formData.assignedDate,
@@ -115,19 +174,19 @@ export const WorkAllocationPage: React.FC = () => {
// Reset form
setFormData({
employeeId: '',
contractorId: '',
subDepartmentId: '',
activity: '',
description: '',
assignedDate: new Date().toISOString().split('T')[0],
rateId: '',
departmentId: isSupervisor ? String(user?.department_id) : '',
units: '',
employeeId: "",
contractorId: "",
subDepartmentId: "",
activity: "",
description: "",
assignedDate: new Date().toISOString().split("T")[0],
rateId: "",
departmentId: isSupervisor ? String(user?.department_id) : "",
units: "",
});
setActiveTab('view');
setActiveTab("view");
} catch (err: any) {
setFormError(err.message || 'Failed to create allocation');
setFormError(err.message || "Failed to create allocation");
} finally {
setFormLoading(false);
}
@@ -135,27 +194,31 @@ export const WorkAllocationPage: React.FC = () => {
const handleMarkComplete = async (id: number) => {
try {
await updateAllocation(id, 'Completed', new Date().toISOString().split('T')[0]);
await updateAllocation(
id,
"Completed",
new Date().toISOString().split("T")[0],
);
} catch (err: any) {
alert(err.message || 'Failed to update allocation');
alert(err.message || "Failed to update allocation");
}
};
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 {
await deleteAllocation(id);
} catch (err: any) {
alert(err.message || 'Failed to delete allocation');
alert(err.message || "Failed to delete allocation");
}
};
// Calculate summary stats
const stats = {
total: allocations.length,
completed: allocations.filter(a => a.status === 'Completed').length,
inProgress: allocations.filter(a => a.status === 'InProgress').length,
pending: allocations.filter(a => a.status === 'Pending').length,
completed: allocations.filter((a) => a.status === "Completed").length,
inProgress: allocations.filter((a) => a.status === "InProgress").length,
pending: allocations.filter((a) => a.status === "Pending").length,
};
return (
@@ -163,29 +226,31 @@ export const WorkAllocationPage: React.FC = () => {
<Card>
<div className="border-b border-gray-200">
<div className="flex space-x-8 px-6">
{['create', 'view', 'summary'].map((tab) => (
<button
{["create", "view", "summary"].map((tab) => (
<button type="button"
key={tab}
onClick={() => setActiveTab(tab as any)}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{tab === 'create' && 'Create Allocation'}
{tab === 'view' && 'View Allocations'}
{tab === 'summary' && 'Work Summary'}
{tab === "create" && "Create Allocation"}
{tab === "view" && "View Allocations"}
{tab === "summary" && "Work Summary"}
</button>
))}
</div>
</div>
<CardContent>
{activeTab === 'create' && (
{activeTab === "create" && (
<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 && (
<div className="p-3 bg-red-100 text-red-700 rounded-md">
{formError}
@@ -200,8 +265,11 @@ export const WorkAllocationPage: React.FC = () => {
onChange={handleInputChange}
required
options={[
{ value: '', label: 'Select Contractor' },
...contractors.map(c => ({ value: String(c.id), label: c.name }))
{ value: "", label: "Select Contractor" },
...contractors.map((c) => ({
value: String(c.id),
label: c.name,
})),
]}
/>
<Select
@@ -211,35 +279,46 @@ export const WorkAllocationPage: React.FC = () => {
onChange={handleInputChange}
required
options={[
{ value: '', label: 'Select Employee' },
...filteredEmployees.map(e => ({ value: String(e.id), label: e.name }))
{ value: "", label: "Select Employee" },
...filteredEmployees.map((e) => ({
value: String(e.id),
label: e.name,
})),
]}
/>
{isSupervisor ? (
<Input
label="Department"
value={supervisorDeptName || '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 }))
]}
/>
)}
{isSupervisor
? (
<Input
label="Department"
value={supervisorDeptName || "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}
options={[
{ value: '', label: 'Select Sub-Department' },
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
{ value: "", label: "Select Sub-Department" },
...subDepartments.map((s) => ({
value: String(s.id),
label: s.name,
})),
]}
/>
<Select
@@ -247,12 +326,22 @@ export const WorkAllocationPage: React.FC = () => {
name="activity"
value={formData.activity}
onChange={handleInputChange}
disabled={!formData.subDepartmentId}
options={[
{ value: '', label: 'Select Activity' },
{ value: 'Loading', label: 'Loading' },
{ value: 'Unloading', label: 'Unloading' },
{ value: 'Standard', label: 'Standard Work' },
{ value: 'Other', label: 'Other' },
{
value: "",
label: formData.subDepartmentId
? "Select Activity"
: "Select Sub-Department First",
},
...activities.map((a) => ({
value: a.name,
label: `${a.name} (${
a.unit_of_measurement === "Per Bag"
? "per unit"
: "flat rate"
})`,
})),
]}
/>
<Input
@@ -270,11 +359,20 @@ export const WorkAllocationPage: React.FC = () => {
onChange={handleInputChange}
disabled={!formData.contractorId}
options={[
{ value: '', label: formData.contractorId ? '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})` : ''}`
}))
{
value: "",
label: formData.contractorId
? "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 && (
@@ -298,37 +396,51 @@ export const WorkAllocationPage: React.FC = () => {
rows={3}
/>
</div>
{/* Calculation Box */}
{selectedRate && (
<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>
<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>
<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>
{isPerUnitRate && (
<>
<div>
<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>
<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 className="mt-4 pt-3 border-t border-blue-300">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-blue-800">Total Amount:</span>
<span className="text-2xl font-bold text-green-600">{totalAmount.toFixed(2)}</span>
<span className="text-lg font-semibold text-blue-800">
Total Amount:
</span>
<span className="text-2xl font-bold text-green-600">
{totalAmount.toFixed(2)}
</span>
</div>
</div>
</div>
@@ -336,11 +448,11 @@ export const WorkAllocationPage: React.FC = () => {
</div>
<div className="flex justify-end gap-4 mt-6">
<Button variant="outline" onClick={() => setActiveTab('view')}>
<Button variant="primary" onClick={() => setActiveTab("view")}>
Cancel
</Button>
<Button onClick={handleCreateAllocation} disabled={formLoading}>
{formLoading ? 'Creating...' : (
<Button variant="primary" onClick={handleCreateAllocation} disabled={formLoading}>
{formLoading ? "Creating..." : (
<>
<Plus size={16} className="mr-2" />
Create Allocation
@@ -351,11 +463,14 @@ export const WorkAllocationPage: React.FC = () => {
</div>
)}
{activeTab === 'view' && (
{activeTab === "view" && (
<div>
<div className="flex gap-4 mb-4">
<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
type="text"
placeholder="Search by employee, contractor, sub-department..."
@@ -375,9 +490,9 @@ export const WorkAllocationPage: React.FC = () => {
Error: {error}
</div>
)}
{(() => {
const filteredAllocations = allocations.filter(a => {
const filteredAllocations = allocations.filter((a) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
@@ -388,122 +503,187 @@ export const WorkAllocationPage: React.FC = () => {
a.status?.toLowerCase().includes(query)
);
});
return loading ? (
<div className="text-center py-8">Loading work allocations...</div>
) : filteredAllocations.length > 0 ? (
<Table>
<TableHeader>
<TableHead>ID</TableHead>
<TableHead>Employee</TableHead>
<TableHead>Contractor</TableHead>
<TableHead>Sub-Department</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Date</TableHead>
<TableHead>Rate Details</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableHeader>
<TableBody>
{filteredAllocations.map((allocation) => {
const isPerUnit = allocation.activity === 'Loading' || allocation.activity === 'Unloading';
const units = parseFloat(allocation.units) || 0;
const rate = parseFloat(allocation.rate) || 0;
const total = parseFloat(allocation.total_amount) || (isPerUnit ? units * rate : rate);
return (
<TableRow key={allocation.id}>
<TableCell>{allocation.id}</TableCell>
<TableCell>{allocation.employee_name || '-'}</TableCell>
<TableCell>{allocation.contractor_name || '-'}</TableCell>
<TableCell>{allocation.sub_department_name || '-'}</TableCell>
<TableCell>
{allocation.activity ? (
<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}
</span>
) : '-'}
</TableCell>
<TableCell>{new Date(allocation.assigned_date).toLocaleDateString()}</TableCell>
<TableCell>
{rate > 0 ? (
<div className="text-sm">
{isPerUnit && units > 0 ? (
<div>
<div className="text-gray-500">{units} × {rate.toFixed(2)}</div>
<div className="font-semibold text-green-600">= {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'
}`}>
{allocation.status}
</span>
</TableCell>
<TableCell>
<div className="flex gap-2">
{allocation.status !== 'Completed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleMarkComplete(allocation.id)}
className="text-green-600"
title="Mark Complete"
return loading
? (
<div className="text-center py-8">
Loading work allocations...
</div>
)
: filteredAllocations.length > 0
? (
<Table>
<TableHeader>
<TableHead>ID</TableHead>
<TableHead>Employee</TableHead>
<TableHead>Contractor</TableHead>
<TableHead>Sub-Department</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Date</TableHead>
<TableHead>Rate Details</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableHeader>
<TableBody>
{filteredAllocations.map((allocation) => {
const isPerUnit = allocation.activity === "Loading" ||
allocation.activity === "Unloading";
const units = parseFloat(allocation.units) || 0;
const rate = parseFloat(allocation.rate) || 0;
const total = parseFloat(allocation.total_amount) ||
(isPerUnit ? units * rate : rate);
return (
<TableRow key={allocation.id}>
<TableCell>{allocation.id}</TableCell>
<TableCell>
{allocation.employee_name || "-"}
</TableCell>
<TableCell>
{allocation.contractor_name || "-"}
</TableCell>
<TableCell>
{allocation.sub_department_name || "-"}
</TableCell>
<TableCell>
{allocation.activity
? (
<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}
</span>
)
: "-"}
</TableCell>
<TableCell>
{new Date(allocation.assigned_date)
.toLocaleDateString()}
</TableCell>
<TableCell>
{rate > 0
? (
<div className="text-sm">
{isPerUnit && units > 0
? (
<div>
<div className="text-gray-500">
{units} × {rate.toFixed(2)}
</div>
<div className="font-semibold text-green-600">
= {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} />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(allocation.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 allocations found.' : 'No work allocations found. Create one to get started!'}
</div>
)
{allocation.status}
</span>
</TableCell>
<TableCell>
<div className="flex gap-2">
{allocation.status !== "Completed" && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleMarkComplete(allocation.id)}
className="text-green-600"
title="Mark Complete"
>
<CheckCircle size={14} />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(allocation.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 allocations found."
: "No work allocations found. Create one to get started!"}
</div>
);
})()}
</div>
)}
{activeTab === 'summary' && (
{activeTab === "summary" && (
<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">
{[
{ label: 'TOTAL ALLOCATIONS', value: stats.total, 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' },
{
label: "TOTAL ALLOCATIONS",
value: stats.total,
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) => (
<div key={stat.label} 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
key={stat.label}
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>

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 {
private baseURL: string;
@@ -8,18 +9,21 @@ class ApiService {
}
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 headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(`${this.baseURL}${endpoint}`, {
@@ -28,178 +32,413 @@ class ApiService {
});
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/';
throw new Error('Unauthorized');
localStorage.removeItem("token");
localStorage.removeItem("user");
globalThis.location.href = "/";
throw new Error("Unauthorized");
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || 'Request failed');
const error = await response.json().catch(() => ({
error: "Request failed",
}));
throw new Error(error.error || "Request failed");
}
return response.json();
}
// Auth
async login(username: string, password: string) {
return this.request<{ token: string; user: any }>('/auth/login', {
method: 'POST',
login(username: string, password: string) {
return this.request<{ token: string; user: any }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
async getMe() {
return this.request<any>('/auth/me');
getMe() {
return this.request<any>("/auth/me");
}
async changePassword(currentPassword: string, newPassword: string) {
return this.request<{ message: string }>('/auth/change-password', {
method: 'POST',
changePassword(currentPassword: string, newPassword: string) {
return this.request<{ message: string }>("/auth/change-password", {
method: "POST",
body: JSON.stringify({ currentPassword, newPassword }),
});
}
// Users
async getUsers(params?: { role?: string; departmentId?: number }) {
getUsers(params?: { role?: string; departmentId?: number }) {
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}`);
}
async createUser(data: any) {
return this.request<any>('/users', {
method: 'POST',
createUser(data: any) {
return this.request<any>("/users", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateUser(id: number, data: any) {
updateUser(id: number, data: any) {
return this.request<any>(`/users/${id}`, {
method: 'PUT',
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteUser(id: number) {
deleteUser(id: number) {
return this.request<{ message: string }>(`/users/${id}`, {
method: 'DELETE',
method: "DELETE",
});
}
// Departments
async getDepartments() {
return this.request<any[]>('/departments');
getDepartments() {
return this.request<any[]>("/departments");
}
async getDepartment(id: number) {
getDepartment(id: number) {
return this.request<any>(`/departments/${id}`);
}
async getSubDepartments(departmentId: number) {
getSubDepartments(departmentId: number) {
return this.request<any[]>(`/departments/${departmentId}/sub-departments`);
}
async createDepartment(name: string) {
return this.request<any>('/departments', {
method: 'POST',
getAllSubDepartments() {
return this.request<any[]>("/departments/sub-departments/all");
}
createDepartment(name: string) {
return this.request<any>("/departments", {
method: "POST",
body: JSON.stringify({ name }),
});
}
// Work Allocations
async getWorkAllocations(params?: { employeeId?: number; status?: string; departmentId?: number }) {
const query = new URLSearchParams(params as any).toString();
return this.request<any[]>(`/work-allocations${query ? `?${query}` : ''}`);
}
async getWorkAllocation(id: number) {
return this.request<any>(`/work-allocations/${id}`);
}
async createWorkAllocation(data: any) {
return this.request<any>('/work-allocations', {
method: 'POST',
// Sub-Departments
createSubDepartment(data: { department_id: number; name: string }) {
return this.request<any>("/departments/sub-departments", {
method: "POST",
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`, {
method: 'PUT',
method: "PUT",
body: JSON.stringify({ status, completionDate }),
});
}
async deleteWorkAllocation(id: number) {
deleteWorkAllocation(id: number) {
return this.request<{ message: string }>(`/work-allocations/${id}`, {
method: 'DELETE',
method: "DELETE",
});
}
// 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();
return this.request<any[]>(`/attendance${query ? `?${query}` : ''}`);
return this.request<any[]>(`/attendance${query ? `?${query}` : ""}`);
}
async checkIn(employeeId: number, workDate: string) {
return this.request<any>('/attendance/check-in', {
method: 'POST',
checkIn(employeeId: number, workDate: string) {
return this.request<any>("/attendance/check-in", {
method: "POST",
body: JSON.stringify({ employeeId, workDate }),
});
}
async checkOut(employeeId: number, workDate: string) {
return this.request<any>('/attendance/check-out', {
method: 'POST',
checkOut(employeeId: number, workDate: string) {
return this.request<any>("/attendance/check-out", {
method: "POST",
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();
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
async getContractorRates(params?: { contractorId?: number; subDepartmentId?: number }) {
const query = params ? new URLSearchParams(params as any).toString() : '';
return this.request<any[]>(`/contractor-rates${query ? `?${query}` : ''}`);
getContractorRates(
params?: { contractorId?: number; subDepartmentId?: number },
) {
const query = params ? new URLSearchParams(params as any).toString() : "";
return this.request<any[]>(`/contractor-rates${query ? `?${query}` : ""}`);
}
async getCurrentRate(contractorId: number, subDepartmentId?: number) {
const query = subDepartmentId ? `?subDepartmentId=${subDepartmentId}` : '';
return this.request<any>(`/contractor-rates/contractor/${contractorId}/current${query}`);
getCurrentRate(contractorId: number, subDepartmentId?: number) {
const query = subDepartmentId ? `?subDepartmentId=${subDepartmentId}` : "";
return this.request<any>(
`/contractor-rates/contractor/${contractorId}/current${query}`,
);
}
async setContractorRate(data: {
contractorId: number;
subDepartmentId?: number;
activity?: string;
rate: number;
effectiveDate: string
setContractorRate(data: {
contractorId: number;
subDepartmentId?: number;
activity?: string;
rate: number;
effectiveDate: string;
}) {
return this.request<any>('/contractor-rates', {
method: 'POST',
return this.request<any>("/contractor-rates", {
method: "POST",
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}`, {
method: 'PUT',
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteContractorRate(id: number) {
deleteContractorRate(id: number) {
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;
sub: string;
activity: string;
status: 'Present' | 'Absent';
status: "Present" | "Absent";
in: string;
out: string;
remark: string;
@@ -56,3 +56,32 @@ export interface ChartData {
color?: 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;
name: string;
email: string;
role: 'SuperAdmin' | 'Supervisor' | 'Contractor' | 'Employee';
role: "SuperAdmin" | "Supervisor" | "Contractor" | "Employee";
department_id?: number;
contractor_id?: number;
is_active: boolean;
created_at: string;
department_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 {
@@ -23,9 +34,20 @@ export interface SubDepartment {
id: number;
department_id: number;
name: string;
primary_activity: string;
created_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 {
@@ -36,7 +58,7 @@ export interface WorkAllocation {
sub_department_id?: number;
description?: string;
assigned_date: string;
status: 'Pending' | 'InProgress' | 'Completed' | 'Cancelled';
status: "Pending" | "InProgress" | "Completed" | "Cancelled";
completion_date?: string;
rate?: number;
created_at: string;
@@ -49,6 +71,13 @@ export interface WorkAllocation {
department_name?: string;
}
export type AttendanceStatus =
| "CheckedIn"
| "CheckedOut"
| "Absent"
| "HalfDay"
| "Late";
export interface Attendance {
id: number;
employee_id: number;
@@ -56,7 +85,8 @@ export interface Attendance {
check_in_time: string;
check_out_time?: string;
work_date: string;
status: 'CheckedIn' | 'CheckedOut';
status: AttendanceStatus;
remark?: string;
created_at: string;
updated_at: string;
employee_name?: string;
@@ -66,13 +96,73 @@ export interface Attendance {
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 {
id: number;
contractor_id: number;
sub_department_id?: number;
activity?: string;
rate: number;
effective_date: string;
created_at: string;
updated_at: string;
contractor_name?: 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
return $?
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 $?
else
# Try using nc to check if port is open
nc -z localhost 3306 &> /dev/null
nc -z localhost 3307 &> /dev/null
return $?
fi
}

View File

@@ -8,4 +8,4 @@ export default {
extend: {},
},
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 react from '@vitejs/plugin-react'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true, // Allow access from any host
allowedHosts: ['all'], // Allow all hosts
allowedHosts: ["all"], // Allow all hosts
},
})
});