(Feat): Initial Commit

This commit is contained in:
2025-11-27 22:50:08 +00:00
commit 00f9ed128b
79 changed files with 17413 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000/api

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

168
QUICK_START.md Normal file
View File

@@ -0,0 +1,168 @@
# Quick Start Guide
## Start the Application
### Option 1: Use the Start Script (Recommended)
```bash
# Terminal 1 - Start MySQL
docker-compose up
# Terminal 2 - Start Application
./start_application.sh
```
### Option 2: Manual Start
```bash
# Terminal 1 - MySQL
docker-compose up
# Terminal 2 - Backend (Deno)
cd backend-deno
deno task dev
# Terminal 3 - Frontend
npm install # First time only
npm run dev
```
### Prerequisites
- **Deno** - Install with: `curl -fsSL https://deno.land/install.sh | sh`
- **Docker** - For MySQL database
- **Node.js** - For frontend only
## Access the Application
- **Frontend**: <http://localhost:5173>
- **Backend API**: <http://localhost:3000>
- **MySQL**: localhost:3306
## Default Login
- **Username**: `admin`
- **Password**: `admin123`
⚠️ **Important**: Change the default password after first login!
## Database Credentials
- **Host**: localhost
- **Port**: 3306
- **Database**: work_allocation
- **User**: workalloc_user
- **Password**: workalloc_pass
- **Root Password**: rootpassword
## Useful Commands
### Docker Management
```bash
# Stop MySQL
docker-compose down
# Stop and remove all data
docker-compose down -v
# View MySQL logs
docker-compose logs -f mysql
# Access MySQL CLI
docker-compose exec mysql mysql -u root -prootpassword work_allocation
```
### Application Management
```bash
# Stop services (Ctrl+C in the terminal running docker-compose up) or docker-compose down
# View backend logs
tail -f backend.log
# View frontend logs
tail -f frontend.log
```
## Next Steps
1. **Login** with admin credentials
2. **Create Departments** (already created: Tudki, Dana, Groundnut)
3. **Create Supervisors** for each department
4. **Create Contractors** and set their rates
5. **Create Employees** and assign to contractors
6. **Start Managing** work allocations and attendance
## System Features
### For SuperAdmin
- Manage all users across all departments
- Create and manage departments
- View all data system-wide
### For Supervisors
- Manage employees and contractors in their department
- Create work allocations
- Check-in/check-out employees
- Set contractor rates
- Mark work as completed
### For Contractors
- View work allocations assigned to them
- View employees under them
### For Employees
- View their work allocations
- View their attendance records
- See contractor rates
## Troubleshooting
### Backend won't connect to database
```bash
# Check if MySQL is running
docker-compose ps
# Restart MySQL
docker-compose restart mysql
```
### Port conflicts
If port 3306 or 3000 is already in use:
```bash
# Stop conflicting services
sudo systemctl stop mysql # If local MySQL is running
# Or change ports in docker-compose.yml and backend/.env
```
### Need to reset database
```bash
docker-compose down -v
./setup-docker.sh
```
## Documentation
- **Full Setup Guide**: `SETUP_GUIDE.md`
- **Docker Details**: `DOCKER_SETUP.md`
- **Backend API**: `backend/README.md`
- **Architecture**: `architecture.md`
## Support
For issues or questions, refer to the documentation files or check the logs:
- Backend: `backend.log`
- Frontend: `frontend.log`
- MySQL: `docker-compose logs mysql`

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
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
## 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).
## 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.

24
backend-deno/.env Normal file
View File

@@ -0,0 +1,24 @@
# Database Configuration
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=admin123
DB_NAME=work_allocation
DB_PORT=3306
# JWT Configuration - CHANGE IN PRODUCTION!
JWT_SECRET=work_alloc_jwt_secret_key_change_in_production_2024
JWT_EXPIRES_IN=7d
# Server Configuration
PORT=3000
# Security Configuration
BCRYPT_ROUNDS=12
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# CORS Configuration (comma-separated for multiple origins)
CORS_ORIGIN=http://localhost:5173
# Environment
NODE_ENV=development

210
backend-deno/README.md Normal file
View File

@@ -0,0 +1,210 @@
# Work Allocation Backend - Deno TypeScript
A secure, type-safe backend for the Work Allocation System built with Deno and TypeScript.
## Features
### Security Improvements
- **Strong Password Hashing**: bcrypt with configurable rounds (default: 12)
- **JWT Authentication**: Secure token-based authentication with HMAC-SHA256
- **Rate Limiting**: Configurable request rate limiting to prevent abuse
- **CORS Protection**: Configurable cross-origin resource sharing
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, XSS protection
- **Input Sanitization**: Protection against XSS and injection attacks
- **Strict TypeScript**: Full type safety with strict compiler options
### Technical Stack
- **Runtime**: Deno 2.x
- **Framework**: Oak (Deno's Express-like framework)
- **Database**: MySQL 8.0 with mysql2 driver
- **Authentication**: JWT with djwt library
- **Password Hashing**: bcrypt
## Prerequisites
- [Deno](https://deno.land/) 2.0 or higher
- MySQL 8.0 (via Docker or local installation)
## Installation
1. **Install Deno** (if not already installed):
```bash
curl -fsSL https://deno.land/install.sh | sh
```
2. **Configure environment**:
```bash
cp .env.example .env
# Edit .env with your database credentials
```
3. **Start the database** (if using Docker):
```bash
cd .. && docker-compose up -d
```
## Running the Server
### Development Mode (with auto-reload)
```bash
deno task dev
```
### Production Mode
```bash
deno task start
```
### Seed the Database
```bash
deno task seed
```
## API Endpoints
### Authentication
- `POST /api/auth/login` - User login
- `GET /api/auth/me` - Get current user
- `POST /api/auth/change-password` - Change password
### Users
- `GET /api/users` - List users (filtered by role)
- `GET /api/users/:id` - Get user by ID
- `POST /api/users` - Create user (Admin/Supervisor)
- `PUT /api/users/:id` - Update user (Admin/Supervisor)
- `DELETE /api/users/:id` - Delete user (Admin/Supervisor)
### Departments
- `GET /api/departments` - List departments
- `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)
### Work Allocations
- `GET /api/work-allocations` - List allocations (role-filtered)
- `GET /api/work-allocations/:id` - Get allocation
- `POST /api/work-allocations` - Create allocation (Supervisor/Admin)
- `PUT /api/work-allocations/:id/status` - Update status (Supervisor/Admin)
- `DELETE /api/work-allocations/:id` - Delete allocation (Supervisor/Admin)
### Attendance
- `GET /api/attendance` - List attendance records
- `GET /api/attendance/:id` - Get attendance record
- `POST /api/attendance/check-in` - Check in employee (Supervisor/Admin)
- `POST /api/attendance/check-out` - Check out employee (Supervisor/Admin)
- `GET /api/attendance/summary/stats` - Get attendance summary
### Contractor Rates
- `GET /api/contractor-rates` - List rates
- `GET /api/contractor-rates/contractor/:id/current` - Get current rate
- `POST /api/contractor-rates` - Set rate (Supervisor/Admin)
- `PUT /api/contractor-rates/:id` - Update rate (Supervisor/Admin)
- `DELETE /api/contractor-rates/:id` - Delete rate (Supervisor/Admin)
### Health Check
- `GET /health` - Server health status
## 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 |
## Security Best Practices
### For Production
1. **Change JWT Secret**: Use a strong, random secret
```bash
JWT_SECRET=$(openssl rand -base64 64)
```
2. **Enable HTTPS**: Use a reverse proxy (nginx) with SSL
3. **Set Production Environment**:
```bash
NODE_ENV=production
```
4. **Increase bcrypt rounds** (if performance allows):
```bash
BCRYPT_ROUNDS=14
```
5. **Configure CORS** for your domain:
```bash
CORS_ORIGIN=https://yourdomain.com
```
## Project Structure
```bash
backend-deno/
├── config/
│ ├── database.ts # Database connection pool
│ └── env.ts # Environment configuration
├── middleware/
│ ├── auth.ts # JWT authentication & authorization
│ └── security.ts # Security middleware (CORS, rate limit, etc.)
├── routes/
│ ├── auth.ts # Authentication routes
│ ├── users.ts # User management routes
│ ├── departments.ts # Department routes
│ ├── work-allocations.ts
│ ├── attendance.ts
│ └── contractor-rates.ts
├── scripts/
│ └── seed.ts # Database seeding script
├── types/
│ └── index.ts # TypeScript type definitions
├── main.ts # Application entry point
├── deno.json # Deno configuration
└── .env # Environment variables
```
## 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 |
## License
MIT

View File

@@ -0,0 +1,79 @@
import { createPool, Pool } from "mysql2/promise";
import { load } from "@std/dotenv";
// Load environment variables
await load({ export: true });
const config = {
host: Deno.env.get("DB_HOST") || "localhost",
user: Deno.env.get("DB_USER") || "root",
password: Deno.env.get("DB_PASSWORD") || "admin123",
database: Deno.env.get("DB_NAME") || "work_allocation",
port: parseInt(Deno.env.get("DB_PORT") || "3306"),
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0,
};
class Database {
private pool: Pool | null = null;
private static instance: Database;
private constructor() {}
static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
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);
throw error;
}
}
return this.pool;
}
async getPool(): Promise<Pool> {
if (!this.pool) {
return await this.connect();
}
return this.pool;
}
async query<T>(sql: string, params?: unknown[]): Promise<T> {
const pool = await this.getPool();
const [rows] = await pool.query(sql, params);
return rows as T;
}
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 };
}
async close(): Promise<void> {
if (this.pool) {
await this.pool.end();
this.pool = null;
console.log("Database connection closed");
}
}
}
export const db = Database.getInstance();
export default db;

View File

@@ -0,0 +1,40 @@
import { load } from "@std/dotenv";
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_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"),
// 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;

19
backend-deno/deno.json Normal file
View File

@@ -0,0 +1,19 @@
{
"tasks": {
"dev": "deno run --watch --allow-net --allow-env --allow-read main.ts",
"start": "deno run --allow-net --allow-env --allow-read main.ts",
"seed": "deno run --allow-net --allow-env --allow-read scripts/seed.ts"
},
"imports": {
"@oak/oak": "jsr:@oak/oak@^17.1.4",
"@std/dotenv": "jsr:@std/dotenv@^0.225.3",
"mysql2": "npm:mysql2@^3.11.0",
"bcrypt": "https://deno.land/x/bcrypt@v0.4.1/mod.ts",
"djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts"
},
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true
}
}

163
backend-deno/deno.lock generated Normal file
View File

@@ -0,0 +1,163 @@
{
"version": "5",
"specifiers": {
"jsr:@oak/commons@1": "1.0.1",
"jsr:@oak/oak@^17.1.4": "17.2.0",
"jsr:@std/assert@1": "1.0.16",
"jsr:@std/bytes@1": "1.0.6",
"jsr:@std/crypto@1": "1.0.5",
"jsr:@std/dotenv@~0.225.3": "0.225.5",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/http@1": "1.0.22",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/media-types@1": "1.1.0",
"jsr:@std/path@1": "1.1.3",
"npm:mysql2@^3.11.0": "3.15.3",
"npm:path-to-regexp@^6.3.0": "6.3.0"
},
"jsr": {
"@oak/commons@1.0.1": {
"integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c",
"dependencies": [
"jsr:@std/assert",
"jsr:@std/bytes",
"jsr:@std/crypto",
"jsr:@std/encoding@1",
"jsr:@std/http",
"jsr:@std/media-types"
]
},
"@oak/oak@17.2.0": {
"integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1",
"dependencies": [
"jsr:@oak/commons",
"jsr:@std/assert",
"jsr:@std/bytes",
"jsr:@std/http",
"jsr:@std/media-types",
"jsr:@std/path",
"npm:path-to-regexp"
]
},
"@std/assert@1.0.16": {
"integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532"
},
"@std/bytes@1.0.6": {
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
},
"@std/crypto@1.0.5": {
"integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40"
},
"@std/dotenv@0.225.5": {
"integrity": "9ce6f9d0ec3311f74a32535aa1b8c62ed88b1ab91b7f0815797d77a6f60c922f"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/http@1.0.22": {
"integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a",
"dependencies": [
"jsr:@std/encoding@^1.0.10"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/path@1.1.3": {
"integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": {
"aws-ssl-profiles@1.1.2": {
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="
},
"denque@2.1.0": {
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="
},
"generate-function@2.3.1": {
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"dependencies": [
"is-property"
]
},
"iconv-lite@0.7.0": {
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"dependencies": [
"safer-buffer"
]
},
"is-property@1.0.2": {
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
},
"long@5.3.2": {
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
},
"lru-cache@7.18.3": {
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="
},
"lru.min@1.1.3": {
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="
},
"mysql2@3.15.3": {
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"dependencies": [
"aws-ssl-profiles",
"denque",
"generate-function",
"iconv-lite",
"long",
"lru.min",
"named-placeholders",
"seq-queue",
"sqlstring"
]
},
"named-placeholders@1.1.3": {
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
"dependencies": [
"lru-cache"
]
},
"path-to-regexp@6.3.0": {
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="
},
"safer-buffer@2.1.2": {
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"seq-queue@0.0.5": {
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"sqlstring@2.3.3": {
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="
}
},
"remote": {
"https://deno.land/std@0.221.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
"https://deno.land/std@0.221.0/encoding/base64.ts": "8ccae67a1227b875340a8582ff707f37b131df435b07080d3bb58e07f5f97807",
"https://deno.land/std@0.221.0/encoding/base64url.ts": "9cc46cf510436be63ac00ebf97a7de1993e603ca58e1853b344bf90d80ea9945",
"https://deno.land/x/bcrypt@v0.4.1/mod.ts": "ff09bdae282583cf5f7d87efe37ddcecef7f14f6d12e8b8066a3058db8c6c2f7",
"https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/base64.ts": "b8266450a4f1eb6960f60f2f7986afc4dde6b45bd2d7ee7ba10789e67e17b9f7",
"https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/bcrypt.ts": "ec221648cc6453ea5e3803bc817c01157dada06aa6f7a0ba6b9f87aae32b21e2",
"https://deno.land/x/bcrypt@v0.4.1/src/main.ts": "08d201b289c8d9c46f8839c69cd6625b213863db29775c7a200afc3b540e64f8",
"https://deno.land/x/bcrypt@v0.4.1/src/worker.ts": "5a73bdfee9c9e622f47c9733d374b627dce52fb3ec1e74c8226698b3fc57ffac",
"https://deno.land/x/djwt@v3.0.2/algorithm.ts": "b1c6645f9dbd6e6c47c123a3b18c28b956f91c65ed17f5b6d5d968fc3750542b",
"https://deno.land/x/djwt@v3.0.2/deps.ts": "a7954fe567f2097b4f6aca11d091b6df658e485a817ac4dee47257ed5c28fd6e",
"https://deno.land/x/djwt@v3.0.2/mod.ts": "962d8f2c4d6a4db111f45d777b152356aec31ba7db0ca664601175a422629857",
"https://deno.land/x/djwt@v3.0.2/signature.ts": "16238fbf558267c85dd6c0178045f006c8b914a7301db87149f3318326569272",
"https://deno.land/x/djwt@v3.0.2/util.ts": "5cb264d2125c553678e11446bcfa0494025d120e3f59d0a3ab38f6800def697d"
},
"workspace": {
"dependencies": [
"jsr:@oak/oak@^17.1.4",
"jsr:@std/dotenv@~0.225.3",
"npm:mysql2@^3.11.0"
]
}
}

107
backend-deno/main.ts Normal file
View File

@@ -0,0 +1,107 @@
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 routes
import authRoutes from "./routes/auth.ts";
import userRoutes from "./routes/users.ts";
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";
// Initialize database connection
await db.connect();
// Create Oak application
const app = new Application();
// Global error handler
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
const error = err as Error & { status?: number };
console.error("Error:", error.message);
ctx.response.status = error.status || 500;
ctx.response.body = {
error: config.isDevelopment() ? error.message : "Internal server error",
};
}
});
// Apply security middleware
app.use(cors);
app.use(securityHeaders);
app.use(requestLogger);
// Rate limiting (only in production or if enabled)
if (config.isProduction()) {
app.use(rateLimit);
}
// Create main router
const router = new Router();
// Health check endpoint
router.get("/health", (ctx) => {
ctx.response.body = {
status: "ok",
timestamp: new Date().toISOString(),
version: "2.0.0-deno",
runtime: "Deno",
};
});
// 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());
// Apply routes
app.use(router.routes());
app.use(router.allowedMethods());
// 404 handler
app.use((ctx) => {
ctx.response.status = 404;
ctx.response.body = { error: "Route not found" };
});
// Graceful shutdown
const controller = new AbortController();
Deno.addSignalListener("SIGINT", () => {
console.log("\n🛑 Shutting down gracefully...");
controller.abort();
db.close();
Deno.exit(0);
});
Deno.addSignalListener("SIGTERM", () => {
console.log("\n🛑 Shutting down gracefully...");
controller.abort();
db.close();
Deno.exit(0);
});
// Start server
console.log(`
╔════════════════════════════════════════════════════════════╗
║ Work Allocation System - Deno Backend v2.0.0 ║
╠════════════════════════════════════════════════════════════╣
║ 🦕 Runtime: Deno ${Deno.version.deno}
║ 🔒 TypeScript with strict type checking ║
║ 🛡️ Security: Rate limiting, CORS, XSS protection ║
╚════════════════════════════════════════════════════════════╝
`);
console.log(`🚀 Server running on http://localhost:${config.PORT}`);
console.log(`📊 Health check: http://localhost:${config.PORT}/health`);
console.log(`🔧 Environment: ${config.NODE_ENV}`);
await app.listen({ port: config.PORT, signal: controller.signal });

View File

@@ -0,0 +1,102 @@
import { Context, Next } from "@oak/oak";
import { verify, create, getNumericDate } from "djwt";
import { config } from "../config/env.ts";
import type { JWTPayload, UserRole } from "../types/index.ts";
// Create crypto key from secret
const encoder = new TextEncoder();
const keyData = encoder.encode(config.JWT_SECRET);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
// Generate JWT token
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")) {
expSeconds = parseInt(expiresIn) * 60 * 60;
} else if (expiresIn.endsWith("m")) {
expSeconds = parseInt(expiresIn) * 60;
}
const token = await create(
{ alg: "HS256", typ: "JWT" },
{
...payload,
exp: getNumericDate(expSeconds),
iat: getNumericDate(0),
},
cryptoKey
);
return token;
}
// Verify JWT token
export async function verifyToken(token: string): Promise<JWTPayload | null> {
try {
const payload = await verify(token, cryptoKey);
return payload as unknown as JWTPayload;
} catch {
return null;
}
}
// Authentication middleware
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();
}
// Authorization middleware factory
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();
};
}
// Get current user from context
export function getCurrentUser(ctx: Context): JWTPayload {
return ctx.state.user as JWTPayload;
}

View File

@@ -0,0 +1,152 @@
import { Context, Next } from "@oak/oak";
import { config } from "../config/env.ts";
// Rate limiting store (in-memory, use Redis in production)
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
// Rate limiting middleware
export async function rateLimit(ctx: Context, next: Next): Promise<void> {
const ip = ctx.request.ip || "unknown";
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 = {
error: "Too many requests",
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");
// Content Security Policy
ctx.response.headers.set(
"Content-Security-Policy",
"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"
);
}
}
// 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());
// Check if origin is allowed
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-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();
}
// Request logging middleware
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`
);
}
// Input sanitization helper
export function sanitizeInput(input: string): string {
return input
.replace(/[<>]/g, "") // Remove angle brackets
.replace(/javascript:/gi, "") // Remove javascript: protocol
.replace(/on\w+=/gi, "") // Remove event handlers
.trim();
}
// Validate email format
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Validate password strength
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" };
}
if (!/[A-Z]/.test(password)) {
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" };
}
if (!/[0-9]/.test(password)) {
return { valid: false, message: "Password must contain at least one number" };
}
return { valid: true };
}
// Clean up old rate limit entries periodically
setInterval(() => {
const now = Date.now();
for (const [ip, record] of rateLimitStore.entries()) {
if (now > record.resetTime) {
rateLimitStore.delete(ip);
}
}
}, 60000); // Clean up every minute

View File

@@ -0,0 +1,293 @@
import { Router } 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";
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 = `
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 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" };
}
});
// 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,
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]
);
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);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// 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.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
d.name as department_name,
c.name as contractor_name
FROM attendance a
JOIN users e ON a.employee_id = e.id
JOIN users s ON a.supervisor_id = s.id
LEFT JOIN departments d ON e.department_id = d.id
LEFT JOIN users c ON e.contractor_id = c.id
WHERE a.id = ?`,
[result.insertId]
);
ctx.response.status = 201;
ctx.response.body = newRecord[0];
} catch (error) {
console.error("Check in error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Check out employee (Supervisor or SuperAdmin)
router.post("/check-out", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
try {
const currentUser = getCurrentUser(ctx);
const body = await ctx.request.body.json() as CheckInOutRequest;
const { employeeId, workDate } = body;
if (!employeeId || !workDate) {
ctx.response.status = 400;
ctx.response.body = { error: "Employee ID and work date required" };
return;
}
// Find the check-in record
let query = "SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?";
const params: unknown[] = [employeeId, workDate, "CheckedIn"];
if (currentUser.role === "Supervisor") {
query += " AND supervisor_id = ?";
params.push(currentUser.id);
}
const records = await db.query<Attendance[]>(query, params);
if (records.length === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "No check-in record found for today" };
return;
}
const checkOutTime = new Date().toISOString().slice(0, 19).replace("T", " ");
await db.execute(
"UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?",
[checkOutTime, "CheckedOut", records[0].id]
);
const updatedRecord = await db.query<Attendance[]>(
`SELECT a.*,
e.name as employee_name, e.username as employee_username,
s.name as supervisor_name,
d.name as department_name,
c.name as contractor_name
FROM attendance a
JOIN users e ON a.employee_id = e.id
JOIN users s ON a.supervisor_id = s.id
LEFT JOIN departments d ON e.department_id = d.id
LEFT JOIN users c ON e.contractor_id = c.id
WHERE a.id = ?`,
[records[0].id]
);
ctx.response.body = updatedRecord[0];
} catch (error) {
console.error("Check out error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// 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 = `
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 queryParams: unknown[] = [];
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" };
}
});
export default router;

163
backend-deno/routes/auth.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Router } from "@oak/oak";
import { hash, compare } 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";
const router = new Router();
// Login
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]
);
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,
username: user.username,
role: user.role,
departmentId: user.department_id,
});
// Return user data without password
const { password: _, ...userWithoutPassword } = user;
ctx.response.body = {
token,
user: userWithoutPassword,
};
} catch (error) {
console.error("Login error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Get current user
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]
);
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);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Change password
router.post("/change-password", authenticateToken, async (ctx) => {
try {
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);
if (!passwordCheck.valid) {
ctx.response.status = 400;
ctx.response.body = { error: passwordCheck.message };
return;
}
} else if (newPassword.length < 6) {
ctx.response.status = 400;
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]
);
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);
// Update password
await db.execute(
"UPDATE users SET password = ? WHERE id = ?",
[hashedPassword, currentUser.id]
);
ctx.response.body = { message: "Password changed successfully" };
} catch (error) {
console.error("Change password error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
export default router;

View File

@@ -0,0 +1,241 @@
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 { 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 = `
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 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" };
}
});
// 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 = `
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 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" };
}
});
// 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.*,
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]
);
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.*,
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 = ?`,
[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;
}
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

@@ -0,0 +1,139 @@
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 { Department, SubDepartment } from "../types/index.ts";
const router = new Router();
// Get all departments
router.get("/", authenticateToken, async (ctx) => {
try {
const departments = await db.query<Department[]>(
"SELECT * FROM departments ORDER BY name"
);
ctx.response.body = departments;
} catch (error) {
console.error("Get departments error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Get department by ID
router.get("/:id", authenticateToken, async (ctx) => {
try {
const deptId = ctx.params.id;
const departments = await db.query<Department[]>(
"SELECT * FROM departments WHERE id = ?",
[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);
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" };
}
});
// Create department (SuperAdmin only)
router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
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 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" };
}
});
export default router;

View File

@@ -0,0 +1,312 @@
import { Router } from "@oak/oak";
import { 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";
const router = new Router();
// Get all users (with filters)
router.get("/", authenticateToken, async (ctx) => {
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,
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 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) {
console.error("Get users error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// Get user by ID
router.get("/:id", authenticateToken, async (ctx) => {
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,
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]
);
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) {
ctx.response.status = 403;
ctx.response.body = { error: "Access denied" };
return;
}
ctx.response.body = users[0];
} catch (error) {
console.error("Get user error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// 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" };
}
});
// 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" };
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);
}
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,
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" };
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" };
}
});
export default router;

View File

@@ -0,0 +1,278 @@
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 { WorkAllocation, CreateWorkAllocationRequest, ContractorRate } 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 = `
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 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" };
}
});
// Get work allocation by ID
router.get("/:id", authenticateToken, async (ctx) => {
try {
const allocationId = ctx.params.id;
const allocations = 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,
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 = ?`,
[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);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
// 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]
);
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.*,
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]
);
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.*,
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 = ?`,
[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);
}
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;

View File

@@ -0,0 +1,247 @@
import { hash } from "bcrypt";
import { db } from "../config/database.ts";
import { config } from "../config/env.ts";
async function seedDatabase() {
try {
console.log("🔌 Connecting to database...");
await db.connect();
console.log("✅ Connected to database\n");
// 1. Seed Departments
console.log("📁 Seeding departments...");
const existingDepts = await db.query<{ count: number }[]>(
"SELECT COUNT(*) as count FROM departments"
);
if (existingDepts[0].count === 0) {
await db.execute(`
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 db.query<{ id: number }[]>(
"SELECT id FROM departments WHERE name = ?",
["Groundnut"]
);
let groundnutId: number | null = null;
if (groundnutDept.length > 0) {
groundnutId = groundnutDept[0].id;
const existingSubDepts = await db.query<{ count: number }[]>(
"SELECT COUNT(*) as count FROM sub_departments WHERE department_id = ?",
[groundnutId]
);
if (existingSubDepts[0].count === 0) {
const subDepts = [
"Mufali Aavak Katai",
"Mufali Aavak Dhang",
"Dhang Se Katai",
"Guthli Bori Silai Dhang",
"Guthali dada Pala Tulai Silai Dhang",
"Mufali Patthar Bori silai dhang",
"Mufali Patthar Bori Utrai",
"Bardana Bandal Loading Unloading",
"Bardana Gatthi Loading",
"Black Dana Loading/Unloading",
"Pre Cleaning",
"Destoner",
"Water",
"Decordicater",
"Round Chalna",
"Cleaning",
"Round Chalna No.1"
];
for (const name of subDepts) {
await db.execute(
"INSERT INTO sub_departments (department_id, name, primary_activity) VALUES (?, ?, ?)",
[groundnutId, name, "Loading/Unloading"]
);
}
console.log(" ✅ Sub-departments created");
} else {
console.log(" Sub-departments already exist");
}
}
// 3. Seed SuperAdmin
console.log("👤 Seeding SuperAdmin user...");
const existingAdmin = await db.query<{ id: number }[]>(
"SELECT id FROM users WHERE username = ?",
["admin"]
);
const adminPassword = await hash("admin123", config.BCRYPT_ROUNDS);
if (existingAdmin.length > 0) {
await db.execute(
"UPDATE users SET password = ?, is_active = TRUE WHERE username = ?",
[adminPassword, "admin"]
);
console.log(" ✅ SuperAdmin password updated");
} else {
await db.execute(
"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 db.query<{ id: number }[]>(
"SELECT id FROM departments WHERE name = ?",
["Tudki"]
);
const danaDept = await db.query<{ id: number }[]>(
"SELECT id FROM departments WHERE name = ?",
["Dana"]
);
const supervisorPassword = await hash("supervisor123", config.BCRYPT_ROUNDS);
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 db.query<{ id: number }[]>(
"SELECT id FROM users WHERE username = ?",
[sup.username]
);
if (existing.length === 0) {
await db.execute(
"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 hash("contractor123", config.BCRYPT_ROUNDS);
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 db.query<{ id: number }[]>(
"SELECT id FROM users WHERE username = ?",
[con.username]
);
if (existing.length === 0) {
await db.execute(
"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 db.query<{ id: number }[]>(
"SELECT id FROM users WHERE username = ?",
["contractor1"]
);
const employeePassword = await hash("employee123", config.BCRYPT_ROUNDS);
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 db.query<{ id: number }[]>(
"SELECT id FROM users WHERE username = ?",
[emp.username]
);
if (existing.length === 0) {
await db.execute(
"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 db.query<{ id: number }[]>(
"SELECT id FROM contractor_rates WHERE contractor_id = ?",
[contractor1[0].id]
);
if (existingRate.length === 0) {
const today = new Date().toISOString().split("T")[0];
await db.execute(
"INSERT INTO contractor_rates (contractor_id, rate, effective_date) VALUES (?, ?, ?)",
[contractor1[0].id, 500.00, today]
);
console.log(" ✅ Contractor rates created");
} else {
console.log(" Contractor rates already exist");
}
}
console.log(`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ Database seeding completed successfully!
🔑 Default Login Credentials:
SuperAdmin:
Username: admin
Password: admin123
Supervisor (Groundnut):
Username: supervisor_groundnut
Password: supervisor123
Contractor:
Username: contractor1
Password: contractor123
Employee:
Username: employee1
Password: employee123
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`);
} catch (error) {
console.error("❌ Error seeding database:", (error as Error).message);
Deno.exit(1);
} finally {
await db.close();
}
}
await seedDatabase();

170
backend-deno/types/index.ts Normal file
View File

@@ -0,0 +1,170 @@
// User types
export type UserRole = "SuperAdmin" | "Supervisor" | "Contractor" | "Employee";
export interface User {
id: number;
username: string;
name: string;
email: string;
password?: string;
role: UserRole;
department_id: number | null;
contractor_id: number | null;
is_active: boolean;
created_at: Date;
department_name?: string;
contractor_name?: string;
}
export interface JWTPayload {
id: number;
username: string;
role: UserRole;
departmentId: number | null;
exp: number;
iat: number;
}
// Department types
export interface Department {
id: number;
name: string;
created_at: Date;
}
export interface SubDepartment {
id: number;
department_id: number;
name: string;
primary_activity: string;
created_at: Date;
}
// Work allocation types
export type AllocationStatus = "Pending" | "InProgress" | "Completed" | "Cancelled";
export interface WorkAllocation {
id: number;
employee_id: number;
supervisor_id: number;
contractor_id: number;
sub_department_id: number | null;
activity: string | null;
description: string | null;
assigned_date: Date;
completion_date: Date | null;
status: AllocationStatus;
rate: number | null;
units: number | null;
total_amount: number | null;
created_at: Date;
employee_name?: string;
supervisor_name?: string;
contractor_name?: string;
sub_department_name?: string;
department_name?: string;
}
// Attendance types
export type AttendanceStatus = "CheckedIn" | "CheckedOut" | "Absent";
export interface Attendance {
id: number;
employee_id: number;
supervisor_id: number;
check_in_time: Date | null;
check_out_time: Date | null;
work_date: Date;
status: AttendanceStatus;
created_at: Date;
employee_name?: string;
supervisor_name?: string;
department_name?: string;
contractor_name?: string;
}
// Contractor rate types
export interface ContractorRate {
id: number;
contractor_id: number;
sub_department_id: number | null;
activity: string | null;
rate: number;
effective_date: Date;
created_at: Date;
contractor_name?: string;
sub_department_name?: string;
department_name?: string;
}
// API response types
export interface ApiError {
error: string;
details?: string;
}
export interface ApiSuccess<T> {
data: T;
message?: string;
}
export interface LoginResponse {
token: string;
user: Omit<User, "password">;
}
// Request body types
export interface LoginRequest {
username: string;
password: string;
}
export interface CreateUserRequest {
username: string;
name: string;
email: string;
password: string;
role: UserRole;
departmentId?: number | null;
contractorId?: number | null;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
role?: UserRole;
departmentId?: number | null;
contractorId?: number | null;
isActive?: boolean;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
export interface CreateWorkAllocationRequest {
employeeId: number;
contractorId: number;
subDepartmentId?: number | null;
activity?: string | null;
description?: string | null;
assignedDate: string;
rate?: number | null;
units?: number | null;
totalAmount?: number | null;
departmentId?: number | null;
}
export interface CheckInOutRequest {
employeeId: number;
workDate: string;
}
export interface CreateContractorRateRequest {
contractorId: number;
subDepartmentId?: number | null;
activity?: string | null;
rate: number;
effectiveDate: string;
}

10
backend/.env Normal file
View File

@@ -0,0 +1,10 @@
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

10
backend/.env.example Normal file
View File

@@ -0,0 +1,10 @@
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

166
backend/README.md Normal file
View File

@@ -0,0 +1,166 @@
# 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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,240 @@
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

@@ -0,0 +1,135 @@
-- Work Allocation System Database Schema
-- This version is for Docker init (database already created by environment variables)
-- Departments table
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 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,
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 (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 TABLE IF NOT EXISTS 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,
activity VARCHAR(100),
description TEXT,
assigned_date DATE NOT NULL,
status ENUM('Pending', 'InProgress', 'Completed', 'Cancelled') DEFAULT 'Pending',
completion_date DATE,
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 TABLE IF NOT EXISTS 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 IGNORE INTO departments (name) VALUES
('Tudki'),
('Dana'),
('Groundnut');
-- 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';
-- Note: Admin user will be created by running: npm run seed
-- This ensures the password is properly hashed with bcrypt

View File

@@ -0,0 +1,11 @@
-- 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

@@ -0,0 +1,13 @@
-- 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;

134
backend/database/schema.sql Normal file
View File

@@ -0,0 +1,134 @@
-- 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

@@ -0,0 +1,72 @@
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

@@ -0,0 +1,32 @@
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 Normal file

File diff suppressed because it is too large Load Diff

20
backend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"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

@@ -0,0 +1,259 @@
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;

114
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,114 @@
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

@@ -0,0 +1,198 @@
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

@@ -0,0 +1,96 @@
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;

236
backend/routes/users.js Normal file
View File

@@ -0,0 +1,236 @@
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

@@ -0,0 +1,244 @@
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

@@ -0,0 +1,13 @@
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.');
});

57
backend/server.js Normal file
View File

@@ -0,0 +1,57 @@
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`);
});

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: work_allocation_db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: admin123
MYSQL_DATABASE: work_allocation
ports:
- "3306: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"]
interval: 5s
timeout: 5s
retries: 10
networks:
- work_allocation_network
volumes:
mysql_data:
driver: local
networks:
work_allocation_network:
driver: bridge

36
eslint.config.js Normal file
View File

@@ -0,0 +1,36 @@
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'] },
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'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',
},
},
)

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4156
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "my-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.2.4"
}
}

2660
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

76
src/App.tsx Normal file
View File

@@ -0,0 +1,76 @@
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';
type PageType = 'dashboard' | 'users' | 'allocation' | 'attendance' | 'rates';
const AppContent: React.FC = () => {
const [activePage, setActivePage] = useState<PageType>('dashboard');
const { isAuthenticated, isLoading } = useAuth();
const renderPage = () => {
switch (activePage) {
case 'dashboard':
return <DashboardPage />;
case 'users':
return <UsersPage />;
case 'allocation':
return <WorkAllocationPage />;
case 'attendance':
return <AttendancePage />;
case 'rates':
return <RatesPage />;
default:
return <DashboardPage />;
}
};
// Show loading state
if (isLoading) {
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>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
// Show login page if not authenticated
if (!isAuthenticated) {
return <LoginPage />;
}
// Show main app if authenticated
return (
<div className="flex h-screen bg-gray-100">
<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>
</div>
</div>
);
};
function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,205 @@
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';
interface ProfilePopupProps {
isOpen: boolean;
onClose: () => void;
onLogout: () => void;
}
// Permission definitions for each role
const rolePermissions: Record<string, { title: string; permissions: string[] }> = {
Supervisor: {
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',
]
},
Employee: {
title: 'Employee Permissions',
permissions: [
'View your work allocations',
'View your attendance records',
'Check-in and check-out',
'View assigned tasks',
]
},
Contractor: {
title: 'Contractor Permissions',
permissions: [
'View assigned work allocations',
'View your rate configurations',
'Track work completion status',
]
},
SuperAdmin: {
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',
]
}
};
const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }) => {
const { user } = useAuth();
const { departments } = useDepartments();
const [showPermissions, setShowPermissions] = useState(false);
if (!isOpen) return null;
const userDepartment = departments.find(d => d.id === user?.department_id);
const userPermissions = rolePermissions[user?.role || 'Employee'];
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">
{/* 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">
<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'}
</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>
<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'}
</span>
</div>
</div>
{/* User Info */}
<div className="px-6 py-4 space-y-3">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<User size={18} className="text-blue-600" />
</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>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<Mail size={18} className="text-purple-600" />
</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>
</div>
</div>
{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>
</div>
</div>
)}
{/* Permissions Section */}
<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"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<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>
</div>
</div>
{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>
<ul className="space-y-2">
{userPermissions.permissions.map((perm, idx) => (
<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>
))}
</ul>
</div>
)}
</div>
{/* Sign Out Button */}
<div className="px-6 pb-4">
<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"
>
<LogOut size={18} />
Sign out
</button>
</div>
</div>
);
};
export const Header: React.FC = () => {
const [isProfileOpen, setIsProfileOpen] = useState(false);
const { user, logout } = useAuth();
const handleLogout = async () => {
await logout();
setIsProfileOpen(false);
};
return (
<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>
</div>
<div className="flex items-center space-x-4">
<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>
</button>
<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'}
</button>
</div>
</div>
<ProfilePopup
isOpen={isProfileOpen}
onClose={() => setIsProfileOpen(false)}
onLogout={handleLogout}
/>
</header>
);
};

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { LayoutDashboard, Users, Briefcase, CalendarCheck, DollarSign, ClipboardList } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
interface SidebarItemProps {
icon: React.ElementType;
label: string;
active: boolean;
onClick: () => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ icon: Icon, label, active, onClick }) => (
<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'
}`}
>
<Icon size={20} />
<span className="font-medium text-sm tracking-wide uppercase">{label}</span>
</button>
);
interface SidebarProps {
activePage: string;
onNavigate: (page: string) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ activePage, onNavigate }) => {
const { user } = useAuth();
const canManageRates = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
return (
<div className="w-64 bg-[#1e293b] flex flex-col">
<div className="p-6 border-b border-gray-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<ClipboardList size={24} className="text-white" />
</div>
<div>
<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">
<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')}
/>
{canManageRates && (
<SidebarItem
icon={DollarSign}
label="Contractor Rates"
active={activePage === 'rates'}
onClick={() => onNavigate('rates')}
/>
)}
</nav>
</div>
);
};

View File

@@ -0,0 +1,43 @@
import React, { ReactNode, ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
fullWidth = false,
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 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',
};
const sizeStyles = {
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' : '';
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${widthStyle} ${className}`}
{...props}
>
{children}
</button>
);
};

View File

@@ -0,0 +1,38 @@
import React, { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
}
export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
return (
<div className={`bg-white rounded-lg shadow-sm ${className}`}>
{children}
</div>
);
};
interface CardHeaderProps {
title: string;
action?: ReactNode;
className?: string;
}
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}`}>
<h2 className="text-xl font-semibold text-gray-800">{title}</h2>
{action && <div>{action}</div>}
</div>
);
};
interface CardContentProps {
children: ReactNode;
className?: string;
}
export const CardContent: React.FC<CardContentProps> = ({ children, className = '' }) => {
return <div className={`p-6 ${className}`}>{children}</div>;
};

View File

@@ -0,0 +1,87 @@
import React, { InputHTMLAttributes } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
required?: boolean;
}
export const Input: React.FC<InputProps> = ({ label, error, required, className = '', disabled, ...props }) => {
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>
)}
<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}`}
disabled={disabled}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
};
interface SelectProps extends InputHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
required?: boolean;
options: { value: string; label: string }[];
}
export const Select: React.FC<SelectProps> = ({ label, error, required, options, className = '', disabled, ...props }) => {
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>
)}
<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}`}
disabled={disabled}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
};
interface TextAreaProps extends InputHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
required?: boolean;
rows?: number;
}
export const TextArea: React.FC<TextAreaProps> = ({ label, error, required, rows = 3, className = '', ...props }) => {
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>
)}
<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' : ''
} ${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
};

View File

@@ -0,0 +1,73 @@
import React, { ReactNode } from 'react';
interface TableProps {
children: ReactNode;
className?: string;
}
export const Table: React.FC<TableProps> = ({ children, className = '' }) => {
return (
<div className="overflow-x-auto">
<table className={`w-full ${className}`}>{children}</table>
</div>
);
};
interface TableHeaderProps {
children: ReactNode;
}
export const TableHeader: React.FC<TableHeaderProps> = ({ children }) => {
return (
<thead>
<tr className="border-b border-gray-200 bg-gray-50">{children}</tr>
</thead>
);
};
interface TableBodyProps {
children: ReactNode;
}
export const TableBody: React.FC<TableBodyProps> = ({ children }) => {
return <tbody>{children}</tbody>;
};
interface TableRowProps {
children: ReactNode;
onClick?: () => void;
className?: string;
}
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}`}
>
{children}
</tr>
);
};
interface TableHeadProps {
children: ReactNode;
className?: string;
}
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}`}>
{children}
</th>
);
};
interface TableCellProps {
children: ReactNode;
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>;
};

View File

@@ -0,0 +1,91 @@
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;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
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');
}
}
setIsLoading(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));
setUser(response.user);
} catch (error) {
console.error('Login failed:', error);
throw error;
}
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
};
const updateUser = (updatedUser: User) => {
setUser(updatedUser);
localStorage.setItem('user', JSON.stringify(updatedUser));
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
updateUser,
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react';
import { api } from '../services/api';
import type { Department, SubDepartment } from '../types';
export const useDepartments = () => {
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchDepartments = async () => {
setLoading(true);
setError(null);
try {
const data = await api.getDepartments();
setDepartments(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch departments');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDepartments();
}, []);
return {
departments,
loading,
error,
refresh: fetchDepartments,
};
};
export const useSubDepartments = (departmentId?: string) => {
const [subDepartments, setSubDepartments] = useState<SubDepartment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchSubDepartments = async () => {
if (!departmentId) {
setSubDepartments([]);
return;
}
setLoading(true);
setError(null);
try {
const data = await api.getSubDepartments(parseInt(departmentId));
setSubDepartments(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch subdepartments');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSubDepartments();
}, [departmentId]);
return {
subDepartments,
loading,
error,
refresh: fetchSubDepartments,
};
};

84
src/hooks/useEmployees.ts Normal file
View File

@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react';
import { api } from '../services/api';
import type { User } from '../types';
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 () => {
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);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEmployees();
}, [JSON.stringify(filters)]);
const createEmployee = async (data: any) => {
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);
throw err;
} finally {
setLoading(false);
}
};
const updateEmployee = async (id: number, data: any) => {
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);
throw err;
} finally {
setLoading(false);
}
};
const deleteEmployee = async (id: number) => {
setLoading(true);
setError(null);
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);
throw err;
} finally {
setLoading(false);
}
};
return {
employees,
loading,
error,
refresh: fetchEmployees,
createEmployee,
updateEmployee,
deleteEmployee,
};
};

View File

@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react';
import { api } from '../services/api';
import type { WorkAllocation } from '../types';
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 () => {
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);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAllocations();
}, [JSON.stringify(filters)]);
const createAllocation = async (data: any) => {
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);
throw err;
} finally {
setLoading(false);
}
};
const updateAllocation = async (id: number, status: string, completionDate?: string) => {
setLoading(true);
setError(null);
try {
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);
throw err;
} finally {
setLoading(false);
}
};
const deleteAllocation = async (id: number) => {
setLoading(true);
setError(null);
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);
throw err;
} finally {
setLoading(false);
}
};
return {
allocations,
loading,
error,
refresh: fetchAllocations,
createAllocation,
updateAllocation,
deleteAllocation,
};
};

3
src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,357 @@
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';
export const AttendancePage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'records' | 'checkin'>('records');
const [attendance, setAttendance] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
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 [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');
// Fetch attendance records
const fetchAttendance = async () => {
setLoading(true);
setError('');
try {
const data = await api.getAttendance();
setAttendance(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch attendance');
} finally {
setLoading(false);
}
};
useEffect(() => {
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');
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');
} finally {
setCheckInLoading(false);
}
};
const handleCheckOut = async () => {
if (!selectedEmployee) {
alert('Please select an employee');
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');
} 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})`
}))
];
// Filter and sort attendance records
const filteredAndSortedAttendance = useMemo(() => {
let filtered = attendance;
// Apply search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
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();
break;
case 'employee':
comparison = (a.employee_name || '').localeCompare(b.employee_name || '');
break;
case 'status':
comparison = (a.status || '').localeCompare(b.status || '');
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
}, [attendance, searchQuery, sortField, sortDirection]);
const handleSort = (field: 'date' | 'employee' | 'status') => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
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'
? <ArrowUp size={14} className="ml-1 text-blue-600" />
: <ArrowDown size={14} className="ml-1 text-blue-600" />;
};
return (
<div className="p-6">
<Card>
<div className="border-b border-gray-200">
<div className="flex space-x-8 px-6">
<button
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'
}`}
>
Attendance Records
</button>
<button
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'
}`}
>
Check In/Out
</button>
</div>
</div>
<CardContent>
{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} />
<input
type="text"
placeholder="Search by employee name or status..."
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={fetchAttendance}>
<RefreshCw size={16} className="mr-2" />
Refresh
</Button>
</div>
<div className="mb-4 text-sm text-gray-600">
Total Records: {filteredAndSortedAttendance.length}
</div>
{error && (
<div className="text-center py-8 text-red-600">
Error: {error}
</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>
)}
</div>
)}
{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>
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<Select
label="Select Employee"
value={selectedEmployee}
onChange={(e) => setSelectedEmployee(e.target.value)}
options={employeeOptions}
/>
<Input
label="Work Date"
type="date"
value={workDate}
onChange={(e) => setWorkDate(e.target.value)}
/>
</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>
)}
<div className="flex justify-center gap-4 pt-4">
<Button
size="lg"
onClick={handleCheckIn}
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status === 'CheckedIn' || employeeStatus?.status === 'CheckedOut'}
>
<LogIn size={16} className="mr-2" />
{checkInLoading ? 'Processing...' : 'Check In'}
</Button>
<Button
size="lg"
variant="outline"
onClick={handleCheckOut}
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status !== 'CheckedIn'}
>
<LogOut size={16} className="mr-2" />
{checkInLoading ? 'Processing...' : 'Check Out'}
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

235
src/pages/DashboardPage.tsx Normal file
View File

@@ -0,0 +1,235 @@
import React, { useState, useEffect } from 'react';
import { Users, Briefcase, Clock, Building2 } from 'lucide-react';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
import { Card, CardHeader, CardContent } from '../components/ui/Card';
import { useEmployees } from '../hooks/useEmployees';
import { useDepartments } from '../hooks/useDepartments';
import { useWorkAllocations } from '../hooks/useWorkAllocations';
import { useAuth } from '../contexts/AuthContext';
import { api } from '../services/api';
export const DashboardPage: React.FC = () => {
const { employees, loading: employeesLoading } = useEmployees();
const { departments, loading: deptLoading } = useDepartments();
const { allocations, loading: allocLoading } = useWorkAllocations();
const { user } = useAuth();
const [attendance, setAttendance] = useState<any[]>([]);
const [roleData, setRoleData] = useState<Array<{ name: string; value: number; fill: string }>>([]);
// Filter departments for supervisors (only show their department)
const isSupervisor = user?.role === 'Supervisor';
const filteredDepartments = isSupervisor
? departments.filter(d => d.id === user?.department_id)
: departments;
// Fetch today's attendance
useEffect(() => {
const today = new Date().toISOString().split('T')[0];
api.getAttendance({ startDate: today, endDate: today })
.then(setAttendance)
.catch(console.error);
}, []);
// Calculate role distribution
useEffect(() => {
if (employees.length > 0) {
const roleCounts: Record<string, number> = {};
employees.forEach(e => {
roleCounts[e.role] = (roleCounts[e.role] || 0) + 1;
});
const colors: Record<string, string> = {
'SuperAdmin': '#8b5cf6',
'Supervisor': '#3b82f6',
'Contractor': '#f59e0b',
'Employee': '#10b981'
};
setRoleData(
Object.entries(roleCounts).map(([role, count]) => ({
name: role,
value: count,
fill: colors[role] || '#6b7280'
}))
);
}
}, [employees]);
const loading = employeesLoading || deptLoading || allocLoading;
// Stats calculations
const stats = {
totalUsers: employees.length,
totalDepartments: filteredDepartments.length,
totalAllocations: allocations.length,
pendingAllocations: allocations.filter(a => a.status === 'Pending').length,
completedAllocations: allocations.filter(a => a.status === 'Completed').length,
todayAttendance: attendance.length,
checkedIn: attendance.filter(a => a.status === 'CheckedIn').length,
checkedOut: attendance.filter(a => a.status === 'CheckedOut').length,
};
return (
<div className="p-6 space-y-6">
{loading && (
<div className="text-center py-4">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading...</span>
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-4 gap-6">
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">TOTAL USERS</h3>
<Users size={20} className="text-blue-600" />
</div>
<div className="text-3xl font-bold text-gray-800">{stats.totalUsers}</div>
<div className="text-xs text-gray-500 mt-1">Registered in system</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">DEPARTMENTS</h3>
<Building2 size={20} className="text-purple-600" />
</div>
<div className="text-3xl font-bold text-gray-800">{stats.totalDepartments}</div>
<div className="text-xs text-gray-500 mt-1">Active departments</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">WORK ALLOCATIONS</h3>
<Briefcase size={20} className="text-orange-600" />
</div>
<div className="text-3xl font-bold text-gray-800">{stats.totalAllocations}</div>
<div className="text-xs text-gray-500 mt-1">
{stats.pendingAllocations} pending, {stats.completedAllocations} completed
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">TODAY'S ATTENDANCE</h3>
<Clock size={20} className="text-green-600" />
</div>
<div className="text-3xl font-bold text-gray-800">{stats.todayAttendance}</div>
<div className="text-xs text-gray-500 mt-1">
{stats.checkedIn} in, {stats.checkedOut} out
</div>
</div>
</div>
{/* Charts Row */}
<div className="grid grid-cols-2 gap-6">
{/* User Distribution */}
<Card>
<CardHeader title="User Distribution by Role" />
<CardContent>
{roleData.length > 0 ? (
<div className="flex items-center">
<div className="w-1/2">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={roleData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={80}
dataKey="value"
>
{roleData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
<div className="w-1/2 space-y-2">
{roleData.map((item) => (
<div key={item.name} className="flex items-center justify-between">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: item.fill }}
></div>
<span className="text-sm text-gray-600">{item.name}</span>
</div>
<span className="text-sm font-medium text-gray-800">{item.value}</span>
</div>
))}
</div>
</div>
) : (
<div className="text-center text-gray-400 py-8">No user data</div>
)}
</CardContent>
</Card>
{/* Departments Overview */}
<Card>
<CardHeader title={isSupervisor ? 'My Department' : 'Departments'} />
<CardContent>
{filteredDepartments.length > 0 ? (
<div className="space-y-4">
{filteredDepartments.map((dept, idx) => {
const deptUsers = employees.filter(e => e.department_id === dept.id).length;
const colors = ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ef4444'];
return (
<div key={dept.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-3"
style={{ backgroundColor: colors[idx % colors.length] }}
></div>
<span className="font-medium text-gray-800">{dept.name}</span>
</div>
<span className="text-sm text-gray-600">{deptUsers} users</span>
</div>
);
})}
</div>
) : (
<div className="text-center text-gray-400 py-8">No departments</div>
)}
</CardContent>
</Card>
</div>
{/* Recent Activity */}
<Card>
<CardHeader title="Recent Work Allocations" />
<CardContent>
{allocations.length > 0 ? (
<div className="space-y-3">
{allocations.slice(0, 5).map((alloc) => (
<div key={alloc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<div className="font-medium text-gray-800">
{alloc.employee_name || 'Unknown Employee'}
</div>
<div className="text-sm text-gray-500">
{alloc.description || 'No description'} • {new Date(alloc.assigned_date).toLocaleDateString()}
</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
alloc.status === 'Completed' ? 'bg-green-100 text-green-700' :
alloc.status === 'InProgress' ? 'bg-blue-100 text-blue-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{alloc.status}
</span>
</div>
))}
</div>
) : (
<div className="text-center text-gray-400 py-8">No recent allocations</div>
)}
</CardContent>
</Card>
</div>
);
};

352
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,352 @@
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';
export const LoginPage: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
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 [forgotLoading, setForgotLoading] = useState(false);
const [forgotSuccess, setForgotSuccess] = useState(false);
const [forgotError, setForgotError] = useState('');
// Auto-hide error after 5 seconds
useEffect(() => {
if (error) {
setShowError(true);
const timer = setTimeout(() => {
setShowError(false);
setTimeout(() => setError(''), 300);
}, 5000);
return () => clearTimeout(timer);
}
}, [error]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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.';
setError(errorMessage);
console.error('Login error:', err);
} finally {
setLoading(false);
}
};
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
setForgotLoading(true);
setForgotError('');
// Simulate API call (replace with actual API call)
try {
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.');
} finally {
setForgotLoading(false);
}
};
const closeForgotModal = () => {
setShowForgotModal(false);
setForgotEmail('');
setForgotSuccess(false);
setForgotError('');
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center p-4 relative overflow-hidden">
{/* Animated background elements */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-pulse delay-1000" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl animate-pulse delay-500" />
</div>
{/* Floating particles */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{[...Array(20)].map((_, i) => (
<div
key={i}
className="absolute w-1 h-1 bg-white/20 rounded-full animate-float"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 5}s`,
animationDuration: `${5 + Math.random() * 10}s`,
}}
/>
))}
</div>
<div className="w-full max-w-md relative z-10">
{/* Login Card */}
<div className="bg-white/10 backdrop-blur-xl rounded-3xl shadow-2xl p-10 border border-white/20">
{/* Logo & Title */}
<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" />
</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>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Username Input */}
<div className="relative group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/70 group-focus-within:text-blue-400 transition-colors">
<Users size={20} />
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
required
autoComplete="username"
className="w-full pl-12 pr-4 py-4 bg-white/10 border border-white/20 rounded-xl text-white placeholder-blue-200/50 focus:outline-none focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 focus:bg-white/15 transition-all"
/>
</div>
{/* Password Input */}
<div className="relative group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/70 group-focus-within:text-blue-400 transition-colors">
<Lock size={20} />
</div>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
autoComplete="current-password"
className="w-full pl-12 pr-12 py-4 bg-white/10 border border-white/20 rounded-xl text-white placeholder-blue-200/50 focus:outline-none focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 focus:bg-white/15 transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-blue-300/70 hover:text-blue-300 transition-colors"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between text-sm">
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={rememberMe}
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>
</label>
<button
type="button"
onClick={() => setShowForgotModal(true)}
className="text-blue-300 hover:text-blue-200 transition-colors hover:underline"
>
Forgot password?
</button>
</div>
{/* Login Button */}
<button
type="submit"
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" />
</>
)}
</button>
</form>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<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>
</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 */}
<div className="text-center mt-6">
<span className="text-blue-300/30 text-xs">v2.0.0</span>
</div>
</div>
{/* 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="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} />
</div>
<div className="flex-1">
<p className="font-semibold">Login Failed</p>
<p className="text-sm text-red-100">{error}</p>
</div>
</div>
</div>
)}
{/* Forgot Password Modal */}
{showForgotModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<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
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} />
</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>
{forgotError && (
<p className="text-red-400 text-sm text-center">{forgotError}</p>
)}
<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
onClick={closeForgotModal}
className="w-full mt-4 text-gray-400 hover:text-white text-sm transition-colors"
>
← Back to login
</button>
</>
) : (
/* Success State */
<div className="text-center py-4">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full shadow-lg mb-4">
<CheckCircle size={32} className="text-white" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Check Your Email</h2>
<p className="text-gray-400 text-sm mb-6">
We've sent password reset instructions to<br />
<span className="text-white font-medium">{forgotEmail}</span>
</p>
<button
onClick={closeForgotModal}
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-semibold py-4 rounded-xl shadow-lg transition-all duration-300"
>
Back to Login
</button>
</div>
)}
</div>
</div>
)}
{/* CSS for floating animation */}
<style>{`
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); opacity: 0.2; }
50% { transform: translateY(-20px) rotate(180deg); opacity: 0.5; }
}
.animate-float {
animation: float linear infinite;
}
`}</style>
</div>
);
};

410
src/pages/RatesPage.tsx Normal file
View File

@@ -0,0 +1,410 @@
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';
export const RatesPage: React.FC = () => {
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('');
// Form state
const [formData, setFormData] = useState({
contractorId: '',
subDepartmentId: '',
activity: '',
rate: '',
effectiveDate: new Date().toISOString().split('T')[0],
});
const [selectedDept, setSelectedDept] = useState('');
const { subDepartments } = useSubDepartments(selectedDept);
const [formError, setFormError] = useState('');
const [formLoading, setFormLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Edit mode
const [editingId, setEditingId] = useState<number | null>(null);
// Fetch rates
const fetchRates = async () => {
setLoading(true);
setError('');
try {
const data = await api.getContractorRates();
setRates(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch 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);
}
};
useEffect(() => {
fetchRates();
fetchContractors();
}, []);
// Auto-select department for supervisors
useEffect(() => {
if (user?.role === 'Supervisor' && user?.department_id) {
setSelectedDept(String(user.department_id));
}
}, [user]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setFormError('');
};
const resetForm = () => {
setFormData({
contractorId: '',
subDepartmentId: '',
activity: '',
rate: '',
effectiveDate: new Date().toISOString().split('T')[0],
});
setEditingId(null);
setFormError('');
};
const handleSubmit = async () => {
if (!formData.contractorId || !formData.rate || !formData.effectiveDate) {
setFormError('Contractor, rate, and effective date are required');
return;
}
setFormLoading(true);
setFormError('');
try {
if (editingId) {
await api.updateContractorRate(editingId, {
rate: parseFloat(formData.rate),
activity: formData.activity || undefined,
effectiveDate: formData.effectiveDate,
});
} else {
await api.setContractorRate({
contractorId: parseInt(formData.contractorId),
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : undefined,
activity: formData.activity || undefined,
rate: parseFloat(formData.rate),
effectiveDate: formData.effectiveDate,
});
}
resetForm();
setActiveTab('list');
fetchRates();
} catch (err: any) {
setFormError(err.message || 'Failed to save rate');
} finally {
setFormLoading(false);
}
};
const handleEdit = (rate: any) => {
setFormData({
contractorId: String(rate.contractor_id),
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],
});
setEditingId(rate.id);
setActiveTab('add');
};
const handleDelete = async (id: number) => {
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');
}
};
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 =>
rate.contractor_name?.toLowerCase().includes(query) ||
rate.sub_department_name?.toLowerCase().includes(query) ||
rate.activity?.toLowerCase().includes(query)
);
}, [rates, 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'
}`}
>
Rate List
</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 Rate'}
</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 contractor, 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={fetchRates}>
<RefreshCw size={16} className="mr-2" />
Refresh
</Button>
</div>
<div className="mb-4 text-sm text-gray-600">
Total Rates: {filteredRates.length}
</div>
{error && (
<div className="text-center py-8 text-red-600">
Error: {error}
</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>
</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 && (
<div className="max-w-2xl space-y-6">
<h3 className="text-lg font-semibold text-gray-800">
{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>
<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>
</ul>
</div>
{formError && (
<div className="p-3 bg-red-100 text-red-700 rounded-md">
{formError}
</div>
)}
<div className="grid grid-cols-2 gap-6">
<Select
label="Contractor"
name="contractorId"
value={formData.contractorId}
onChange={handleInputChange}
required
disabled={!!editingId}
options={[
{ 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 }))
]}
/>
)}
<Select
label="Sub-Department"
name="subDepartmentId"
value={formData.subDepartmentId}
onChange={handleInputChange}
disabled={!!editingId}
options={[
{ value: '', label: 'Select Sub-Department (Optional)' },
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
]}
/>
<Select
label="Activity Type"
name="activity"
value={formData.activity}
onChange={handleInputChange}
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)' },
]}
/>
<Input
label={formData.activity === 'Loading' || formData.activity === 'Unloading'
? "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 Rate'}
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

786
src/pages/UsersPage.tsx Normal file
View File

@@ -0,0 +1,786 @@
import React, { useState, useMemo } from 'react';
import { RefreshCw, Plus, Trash2, Edit, Save, X, Search, AlertTriangle, UserX } 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 { useEmployees } from '../hooks/useEmployees';
import { useDepartments } from '../hooks/useDepartments';
import { useAuth } from '../contexts/AuthContext';
import { api } from '../services/api';
export const UsersPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'list' | 'add' | 'edit' | 'delete'>('list');
const [filterRole, setFilterRole] = useState('');
const [filterDept, setFilterDept] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const { employees, loading, error, refresh, createEmployee, deleteEmployee, updateEmployee } = useEmployees();
const { departments } = useDepartments();
const { user: currentUser } = useAuth();
// Form state
const [formData, setFormData] = useState({
username: '',
password: '',
confirmPassword: '',
name: '',
email: '',
role: 'Employee',
departmentId: '',
contractorId: '',
isActive: true,
});
const [formError, setFormError] = useState('');
const [formLoading, setFormLoading] = useState(false);
const [contractors, setContractors] = useState<any[]>([]);
const [editingUserId, setEditingUserId] = useState<number | null>(null);
// Load contractors when role is Employee
React.useEffect(() => {
if (formData.role === 'Employee') {
api.getUsers({ role: 'Contractor' }).then(setContractors).catch(console.error);
}
}, [formData.role]);
// Check if current user can manage users
const canManageUsers = currentUser?.role === 'SuperAdmin' || currentUser?.role === 'Supervisor';
const isSupervisor = currentUser?.role === 'Supervisor';
// Filter departments for supervisors (only show their department)
const filteredDepartments = isSupervisor
? departments.filter(d => d.id === currentUser?.department_id)
: departments;
const roleOptions = [
{ value: '', label: 'All Roles' },
{ value: 'SuperAdmin', label: 'Super Admin' },
{ value: 'Supervisor', label: 'Supervisor' },
{ value: 'Contractor', label: 'Contractor' },
{ value: 'Employee', label: 'Employee' },
];
const deptOptions = isSupervisor
? [{ value: String(currentUser?.department_id), label: filteredDepartments[0]?.name || 'My Department' }]
: [
{ value: '', label: 'All Departments' },
...departments.map(d => ({ value: String(d.id), label: d.name })),
];
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setFormError('');
};
const handleCreateUser = async () => {
// Validation
if (!formData.username || !formData.password || !formData.name || !formData.email) {
setFormError('Please fill in all required fields');
return;
}
if (formData.password !== formData.confirmPassword) {
setFormError('Passwords do not match');
return;
}
if (formData.password.length < 6) {
setFormError('Password must be at least 6 characters');
return;
}
setFormLoading(true);
setFormError('');
try {
await createEmployee({
username: formData.username,
password: formData.password,
name: formData.name,
email: formData.email,
role: formData.role,
departmentId: formData.departmentId ? parseInt(formData.departmentId) : null,
contractorId: formData.contractorId ? parseInt(formData.contractorId) : null,
});
// Reset form and switch to list
setFormData({
username: '',
password: '',
confirmPassword: '',
name: '',
email: '',
role: 'Employee',
departmentId: '',
contractorId: '',
});
setActiveTab('list');
refresh();
} catch (err: any) {
setFormError(err.message || 'Failed to create user');
} finally {
setFormLoading(false);
}
};
const handleDeleteUser = async (id: number, username: string) => {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) return;
try {
await deleteEmployee(id);
refresh();
} catch (err: any) {
alert(err.message || 'Failed to delete user');
}
};
const handleEditUser = (user: any) => {
setFormData({
username: user.username,
password: '',
confirmPassword: '',
name: user.name,
email: user.email,
role: user.role,
departmentId: user.department_id ? String(user.department_id) : '',
contractorId: user.contractor_id ? String(user.contractor_id) : '',
isActive: user.is_active,
});
setEditingUserId(user.id);
setActiveTab('edit');
setFormError('');
};
const handleUpdateUser = async () => {
if (!formData.name || !formData.email) {
setFormError('Please fill in all required fields');
return;
}
setFormLoading(true);
setFormError('');
try {
await updateEmployee(editingUserId!, {
name: formData.name,
email: formData.email,
role: formData.role,
departmentId: formData.departmentId ? parseInt(formData.departmentId) : null,
contractorId: formData.contractorId ? parseInt(formData.contractorId) : null,
isActive: formData.isActive,
});
resetForm();
setActiveTab('list');
refresh();
} catch (err: any) {
setFormError(err.message || 'Failed to update user');
} finally {
setFormLoading(false);
}
};
const resetForm = () => {
setFormData({
username: '',
password: '',
confirmPassword: '',
name: '',
email: '',
role: 'Employee',
departmentId: '',
contractorId: '',
isActive: true,
});
setEditingUserId(null);
setFormError('');
};
// Auto-set filter for supervisors
React.useEffect(() => {
if (isSupervisor && currentUser?.department_id) {
setFilterDept(String(currentUser.department_id));
}
}, [isSupervisor, currentUser?.department_id]);
// Filter employees
const filteredEmployees = employees.filter(emp => {
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesSearch =
emp.name?.toLowerCase().includes(query) ||
emp.username?.toLowerCase().includes(query) ||
emp.email?.toLowerCase().includes(query) ||
emp.role?.toLowerCase().includes(query);
if (!matchesSearch) return false;
}
if (filterRole && emp.role !== filterRole) return false;
// For supervisors, always filter by their department
if (isSupervisor && currentUser?.department_id) {
if (emp.department_id !== currentUser.department_id) return false;
} else if (filterDept && emp.department_id !== parseInt(filterDept)) {
return false;
}
return true;
});
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'
}`}
>
User List
</button>
{canManageUsers && (
<>
<button
onClick={() => { setActiveTab('add'); resetForm(); }}
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'
}`}
>
Add User
</button>
<button
onClick={() => setActiveTab('edit')}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === 'edit'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Edit User
</button>
<button
onClick={() => setActiveTab('delete')}
className={`py-4 px-2 border-b-2 font-medium text-sm ${
activeTab === 'delete'
? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Delete Users
</button>
</>
)}
</div>
</div>
<CardContent>
{activeTab === 'list' && (
<div>
<div className="flex gap-4 mb-6">
<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 users by name, username, email..."
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>
<Select
options={deptOptions}
className="w-48 flex-shrink-0"
value={filterDept}
onChange={(e) => setFilterDept(e.target.value)}
disabled={isSupervisor}
/>
<Select
options={roleOptions}
className="w-48 flex-shrink-0"
value={filterRole}
onChange={(e) => setFilterRole(e.target.value)}
/>
<Button variant="ghost" onClick={refresh}>
<RefreshCw size={16} className="mr-2" />
Refresh
</Button>
</div>
<div className="mb-4 text-sm text-gray-600">
Total Users: {filteredEmployees.length}
</div>
{error && (
<div className="text-center py-8 text-red-600">
Error: {error}
</div>
)}
{loading ? (
<div className="text-center py-8">Loading...</div>
) : filteredEmployees.length > 0 ? (
<Table>
<TableHeader>
<TableHead>ID</TableHead>
<TableHead>USERNAME</TableHead>
<TableHead>FULL NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>DEPARTMENT</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>ACTIONS</TableHead>
</TableHeader>
<TableBody>
{filteredEmployees.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.id}</TableCell>
<TableCell className="text-blue-600">{user.username}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<span className={`px-2 py-1 rounded text-xs font-medium ${
user.role === 'SuperAdmin' ? 'bg-purple-100 text-purple-700' :
user.role === 'Supervisor' ? 'bg-blue-100 text-blue-700' :
user.role === 'Contractor' ? 'bg-orange-100 text-orange-700' :
'bg-gray-100 text-gray-700'
}`}>
{user.role}
</span>
</TableCell>
<TableCell>{user.department_name || '-'}</TableCell>
<TableCell>
<span className={`px-2 py-1 rounded text-xs font-medium ${
user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</TableCell>
<TableCell>
{canManageUsers && (
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(user)}
className="text-blue-600 hover:text-blue-800"
title="Edit"
>
<Edit size={14} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUser(user.id, user.username)}
className="text-red-600 hover:text-red-800"
title="Delete"
>
<Trash2 size={14} />
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : !loading && (
<div className="text-center py-8 text-gray-500">
No users found
</div>
)}
</div>
)}
{activeTab === 'add' && (
<div className="max-w-3xl">
{formError && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
{formError}
</div>
)}
<h3 className="text-lg font-semibold text-gray-800 mb-6">User Information</h3>
<div className="grid grid-cols-2 gap-6 mb-8">
<Input
label="Username"
name="username"
value={formData.username}
onChange={handleInputChange}
required
/>
<Input
label="Password"
name="password"
type="password"
value={formData.password}
onChange={handleInputChange}
required
/>
<Input
label="Full Name"
name="name"
value={formData.name}
onChange={handleInputChange}
required
/>
<Input
label="Confirm Password"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleInputChange}
required
/>
<div className="col-span-2">
<Input
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
required
/>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-6">Role & Department</h3>
<div className="grid grid-cols-2 gap-6 mb-8">
<Select
label="Role"
name="role"
value={formData.role}
onChange={handleInputChange}
required
options={roleOptions.slice(1)}
/>
<Select
label="Department"
name="departmentId"
value={formData.departmentId}
onChange={handleInputChange}
options={deptOptions.slice(1)}
/>
{formData.role === 'Employee' && (
<Select
label="Contractor (for Employees)"
name="contractorId"
value={formData.contractorId}
onChange={handleInputChange}
options={[
{ value: '', label: 'Select Contractor' },
...contractors.map(c => ({ value: String(c.id), label: c.name }))
]}
/>
)}
</div>
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={() => { setActiveTab('list'); resetForm(); }}
>
Cancel
</Button>
<Button
size="lg"
onClick={handleCreateUser}
disabled={formLoading}
>
{formLoading ? (
'Creating...'
) : (
<>
<Plus size={16} className="mr-2" />
Create User
</>
)}
</Button>
</div>
</div>
)}
{activeTab === 'edit' && (
<div className="max-w-3xl">
{formError && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
{formError}
</div>
)}
{!editingUserId ? (
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-6">Select User to Edit</h3>
<Select
label="Select User"
value=""
onChange={(e) => {
const user = employees.find(emp => emp.id === parseInt(e.target.value));
if (user) handleEditUser(user);
}}
options={[
{ value: '', label: 'Choose a user to edit...' },
...employees.map(emp => ({
value: String(emp.id),
label: `${emp.name} (${emp.username}) - ${emp.role}`
}))
]}
/>
</div>
) : (
<>
<h3 className="text-lg font-semibold text-gray-800 mb-6">Edit User: {formData.username}</h3>
<div className="grid grid-cols-2 gap-6 mb-8">
<Input
label="Username"
name="username"
value={formData.username}
disabled
/>
<Input
label="Full Name"
name="name"
value={formData.name}
onChange={handleInputChange}
required
/>
<Input
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
required
/>
<Select
label="Status"
name="isActive"
value={formData.isActive ? 'true' : 'false'}
onChange={(e) => setFormData(prev => ({ ...prev, isActive: e.target.value === 'true' }))}
options={[
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
]}
/>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-6">Role & Department</h3>
<div className="grid grid-cols-2 gap-6 mb-8">
<Select
label="Role"
name="role"
value={formData.role}
onChange={handleInputChange}
required
options={roleOptions.slice(1)}
/>
<Select
label="Department"
name="departmentId"
value={formData.departmentId}
onChange={handleInputChange}
options={[
{ value: '', label: 'No Department' },
...departments.map(d => ({ value: String(d.id), label: d.name }))
]}
/>
{formData.role === 'Employee' && (
<Select
label="Contractor (for Employees)"
name="contractorId"
value={formData.contractorId}
onChange={handleInputChange}
options={[
{ value: '', label: 'Select Contractor' },
...contractors.map(c => ({ value: String(c.id), label: c.name }))
]}
/>
)}
</div>
<div className="flex justify-end gap-4">
<Button
variant="ghost"
onClick={() => resetForm()}
>
<X size={16} className="mr-2" />
Clear Selection
</Button>
<Button
onClick={handleUpdateUser}
disabled={formLoading}
>
{formLoading ? (
'Saving...'
) : (
<>
<Save size={16} className="mr-2" />
Save Changes
</>
)}
</Button>
</div>
</>
)}
</div>
)}
{activeTab === 'delete' && canManageUsers && (
<div>
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
<div>
<h4 className="font-semibold text-red-800">Warning: Permanent Action</h4>
<p className="text-sm text-red-700 mt-1">
Deleting a user is permanent and cannot be undone. All associated data will be removed.
{isSupervisor && " As a Supervisor, you can only delete Employees and Contractors in your department."}
</p>
</div>
</div>
</div>
<div className="flex gap-4 mb-6">
<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 users to delete..."
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-red-500 focus:border-transparent"
/>
</div>
{!isSupervisor && (
<Select
options={deptOptions}
className="w-48 flex-shrink-0"
value={filterDept}
onChange={(e) => setFilterDept(e.target.value)}
/>
)}
<Select
options={[
{ value: '', label: 'All Deletable Roles' },
{ value: 'Employee', label: 'Employee' },
{ value: 'Contractor', label: 'Contractor' },
]}
className="w-48 flex-shrink-0"
value={filterRole}
onChange={(e) => setFilterRole(e.target.value)}
/>
</div>
{(() => {
// Filter users that can be deleted
const deletableUsers = employees.filter(emp => {
// SuperAdmins and Supervisors cannot be deleted from this tab
if (emp.role === 'SuperAdmin' || emp.role === 'Supervisor') return false;
// Only Employees and Contractors can be deleted
if (emp.role !== 'Employee' && emp.role !== 'Contractor') return false;
// Supervisors can only delete users in their department
if (isSupervisor && emp.department_id !== currentUser?.department_id) return false;
// Apply search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesSearch =
emp.name?.toLowerCase().includes(query) ||
emp.username?.toLowerCase().includes(query) ||
emp.email?.toLowerCase().includes(query);
if (!matchesSearch) return false;
}
// Apply role filter
if (filterRole && emp.role !== filterRole) return false;
// Apply department filter (for SuperAdmin)
if (!isSupervisor && filterDept && emp.department_id !== parseInt(filterDept)) return false;
return true;
});
return (
<>
<div className="mb-4 text-sm text-gray-600">
Deletable Users: {deletableUsers.length}
</div>
{deletableUsers.length > 0 ? (
<Table>
<TableHeader>
<TableHead>ID</TableHead>
<TableHead>USERNAME</TableHead>
<TableHead>FULL NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>DEPARTMENT</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>ACTION</TableHead>
</TableHeader>
<TableBody>
{deletableUsers.map((user) => (
<TableRow key={user.id} className="hover:bg-red-50">
<TableCell>{user.id}</TableCell>
<TableCell className="text-blue-600">{user.username}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<span className={`px-2 py-1 rounded text-xs font-medium ${
user.role === 'Contractor' ? 'bg-orange-100 text-orange-700' :
'bg-gray-100 text-gray-700'
}`}>
{user.role}
</span>
</TableCell>
<TableCell>{user.department_name || '-'}</TableCell>
<TableCell>
<span className={`px-2 py-1 rounded text-xs font-medium ${
user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm(`Are you sure you want to permanently delete "${user.name}" (${user.username})?\n\nThis action cannot be undone!`)) {
deleteEmployee(user.id);
}
}}
className="text-red-600 border-red-300 hover:bg-red-50 hover:border-red-400"
>
<UserX size={14} className="mr-1" />
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-gray-500">
<UserX size={48} className="mx-auto mb-4 text-gray-300" />
<p>No deletable users found</p>
<p className="text-sm mt-1">
{isSupervisor
? "Only Employees and Contractors in your department can be deleted."
: "Only Employees and Contractors can be deleted from this tab."}
</p>
</div>
)}
</>
);
})()}
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,516 @@
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';
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 { 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';
// Get supervisor's department 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: '',
});
const [selectedDept, setSelectedDept] = useState('');
const { subDepartments } = useSubDepartments(selectedDept);
const [formError, setFormError] = useState('');
const [formLoading, setFormLoading] = useState(false);
const [contractorRates, setContractorRates] = useState<any[]>([]);
// Fetch contractor rates when contractor changes
useEffect(() => {
if (formData.contractorId) {
api.getContractorRates({ contractorId: parseInt(formData.contractorId) })
.then(setContractorRates)
.catch(console.error);
} else {
setContractorRates([]);
}
}, [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';
// Calculate total amount
const unitCount = parseFloat(formData.units) || 0;
const rateAmount = parseFloat(selectedRate?.rate) || 0;
const totalAmount = isPerUnitRate ? unitCount * rateAmount : rateAmount;
// Auto-select department for supervisors when user data loads
useEffect(() => {
if (isSupervisor && user?.department_id) {
const deptId = String(user.department_id);
setSelectedDept(deptId);
setFormData(prev => ({ ...prev, departmentId: deptId }));
}
}, [isSupervisor, user?.department_id]);
// Load contractors
useEffect(() => {
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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setFormError('');
};
const handleCreateAllocation = async () => {
if (!formData.employeeId || !formData.contractorId) {
setFormError('Please select employee and contractor');
return;
}
setFormLoading(true);
setFormError('');
try {
await createAllocation({
employeeId: parseInt(formData.employeeId),
contractorId: parseInt(formData.contractorId),
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : null,
activity: formData.activity || null,
description: formData.description,
assignedDate: formData.assignedDate,
rate: selectedRate?.rate || null,
units: isPerUnitRate ? unitCount : null,
totalAmount: totalAmount || null,
});
// Reset form
setFormData({
employeeId: '',
contractorId: '',
subDepartmentId: '',
activity: '',
description: '',
assignedDate: new Date().toISOString().split('T')[0],
rateId: '',
departmentId: isSupervisor ? String(user?.department_id) : '',
units: '',
});
setActiveTab('view');
} catch (err: any) {
setFormError(err.message || 'Failed to create allocation');
} finally {
setFormLoading(false);
}
};
const handleMarkComplete = async (id: number) => {
try {
await updateAllocation(id, 'Completed', new Date().toISOString().split('T')[0]);
} catch (err: any) {
alert(err.message || 'Failed to update allocation');
}
};
const handleDelete = async (id: number) => {
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');
}
};
// 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,
};
return (
<div className="p-6">
<Card>
<div className="border-b border-gray-200">
<div className="flex space-x-8 px-6">
{['create', 'view', 'summary'].map((tab) => (
<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'
}`}
>
{tab === 'create' && 'Create Allocation'}
{tab === 'view' && 'View Allocations'}
{tab === 'summary' && 'Work Summary'}
</button>
))}
</div>
</div>
<CardContent>
{activeTab === 'create' && (
<div className="max-w-3xl space-y-6">
<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}
</div>
)}
<div className="grid grid-cols-2 gap-6">
<Select
label="Contractor"
name="contractorId"
value={formData.contractorId}
onChange={handleInputChange}
required
options={[
{ value: '', label: 'Select Contractor' },
...contractors.map(c => ({ value: String(c.id), label: c.name }))
]}
/>
<Select
label="Employee"
name="employeeId"
value={formData.employeeId}
onChange={handleInputChange}
required
options={[
{ 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 }))
]}
/>
)}
<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 }))
]}
/>
<Select
label="Activity"
name="activity"
value={formData.activity}
onChange={handleInputChange}
options={[
{ value: '', label: 'Select Activity' },
{ value: 'Loading', label: 'Loading' },
{ value: 'Unloading', label: 'Unloading' },
{ value: 'Standard', label: 'Standard Work' },
{ value: 'Other', label: 'Other' },
]}
/>
<Input
label="Assigned Date"
name="assignedDate"
type="date"
value={formData.assignedDate}
onChange={handleInputChange}
required
/>
<Select
label="Rate"
name="rateId"
value={formData.rateId}
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})` : ''}`
}))
]}
/>
{isPerUnitRate && (
<Input
label="Number of Units"
name="units"
type="number"
value={formData.units}
onChange={handleInputChange}
placeholder="Enter units"
required
/>
)}
<div className="col-span-2">
<TextArea
label="Description"
name="description"
value={formData.description}
onChange={handleInputChange}
placeholder="Work description..."
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>
<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>
</div>
<div>
<span className="text-gray-600">Rate:</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>
</div>
<div>
<span className="text-gray-600">Calculation:</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>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-4 mt-6">
<Button variant="outline" onClick={() => setActiveTab('view')}>
Cancel
</Button>
<Button onClick={handleCreateAllocation} disabled={formLoading}>
{formLoading ? 'Creating...' : (
<>
<Plus size={16} className="mr-2" />
Create Allocation
</>
)}
</Button>
</div>
</div>
)}
{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} />
<input
type="text"
placeholder="Search by employee, contractor, sub-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-blue-500 focus:border-transparent"
/>
</div>
<Button variant="ghost" onClick={refresh}>
<RefreshCw size={16} className="mr-2" />
Refresh
</Button>
</div>
{error && (
<div className="text-center py-8 text-red-600">
Error: {error}
</div>
)}
{(() => {
const filteredAllocations = allocations.filter(a => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
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.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"
>
<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' && (
<div>
<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' },
].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>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

207
src/services/api.ts Normal file
View File

@@ -0,0 +1,207 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
class ApiService {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private getToken(): string | null {
return localStorage.getItem('token');
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = this.getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${this.baseURL}${endpoint}`, {
...options,
headers,
});
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.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');
}
return response.json();
}
// Auth
async 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');
}
async 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 }) {
const query = new URLSearchParams(params as any).toString();
return this.request<any[]>(`/users${query ? `?${query}` : ''}`);
}
async getUser(id: number) {
return this.request<any>(`/users/${id}`);
}
async createUser(data: any) {
return this.request<any>('/users', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateUser(id: number, data: any) {
return this.request<any>(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteUser(id: number) {
return this.request<{ message: string }>(`/users/${id}`, {
method: 'DELETE',
});
}
// Departments
async getDepartments() {
return this.request<any[]>('/departments');
}
async getDepartment(id: number) {
return this.request<any>(`/departments/${id}`);
}
async getSubDepartments(departmentId: number) {
return this.request<any[]>(`/departments/${departmentId}/sub-departments`);
}
async 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',
body: JSON.stringify(data),
});
}
async updateWorkAllocationStatus(id: number, status: string, completionDate?: string) {
return this.request<any>(`/work-allocations/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status, completionDate }),
});
}
async deleteWorkAllocation(id: number) {
return this.request<{ message: string }>(`/work-allocations/${id}`, {
method: 'DELETE',
});
}
// Attendance
async 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}` : ''}`);
}
async 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',
body: JSON.stringify({ employeeId, workDate }),
});
}
async 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}` : ''}`);
}
// 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}` : ''}`);
}
async 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
}) {
return this.request<any>('/contractor-rates', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateContractorRate(id: number, data: { rate?: number; activity?: string; effectiveDate?: string }) {
return this.request<any>(`/contractor-rates/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteContractorRate(id: number) {
return this.request<{ message: string }>(`/contractor-rates/${id}`, {
method: 'DELETE',
});
}
}
export const api = new ApiService(API_BASE_URL);

58
src/types.ts Normal file
View File

@@ -0,0 +1,58 @@
export interface Employee {
id: string;
name: string;
dept: string;
sub: string;
activity: string;
status: 'Present' | 'Absent';
in: string;
out: string;
remark: string;
}
export interface Contractor {
id: string;
name: string;
role: string;
employees: Employee[];
}
export interface Supervisor {
id: string;
name: string;
role: string;
dept: string;
contractors: Contractor[];
}
export interface User {
id: number;
username: string;
name: string;
role: string;
dept: string;
status: string;
}
export interface Allocation {
id: number;
empId: number;
employee: string;
contractor: string;
activity: string;
date: string;
totalQty: number;
completed: number;
remaining: number;
rate: number;
amount: number;
paid: number;
status: string;
}
export interface ChartData {
name: string;
value: number;
color?: string;
fill?: string;
}

78
src/types/index.ts Normal file
View File

@@ -0,0 +1,78 @@
export interface User {
id: number;
username: string;
name: string;
email: string;
role: 'SuperAdmin' | 'Supervisor' | 'Contractor' | 'Employee';
department_id?: number;
contractor_id?: number;
is_active: boolean;
created_at: string;
department_name?: string;
contractor_name?: string;
}
export interface Department {
id: number;
name: string;
created_at: string;
updated_at: string;
}
export interface SubDepartment {
id: number;
department_id: number;
name: string;
primary_activity: string;
created_at: string;
updated_at: string;
}
export interface WorkAllocation {
id: number;
employee_id: number;
supervisor_id: number;
contractor_id: number;
sub_department_id?: number;
description?: string;
assigned_date: string;
status: 'Pending' | 'InProgress' | 'Completed' | 'Cancelled';
completion_date?: string;
rate?: number;
created_at: string;
updated_at: string;
employee_name?: string;
employee_username?: string;
supervisor_name?: string;
contractor_name?: string;
sub_department_name?: string;
department_name?: string;
}
export interface Attendance {
id: number;
employee_id: number;
supervisor_id: number;
check_in_time: string;
check_out_time?: string;
work_date: string;
status: 'CheckedIn' | 'CheckedOut';
created_at: string;
updated_at: string;
employee_name?: string;
employee_username?: string;
supervisor_name?: string;
department_name?: string;
contractor_name?: string;
}
export interface ContractorRate {
id: number;
contractor_id: number;
rate: number;
effective_date: string;
created_at: string;
updated_at: string;
contractor_name?: string;
contractor_username?: string;
}

258
start_application.sh Executable file
View File

@@ -0,0 +1,258 @@
#!/bin/bash
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Parse command line arguments
SEED_DB=false
SHOW_HELP=false
while [[ $# -gt 0 ]]; do
case $1 in
--seed|-s)
SEED_DB=true
shift
;;
--help|-h)
SHOW_HELP=true
shift
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
SHOW_HELP=true
shift
;;
esac
done
# Show help
if [ "$SHOW_HELP" = true ]; then
echo -e "${BLUE}Work Allocation System - Startup Script${NC}"
echo ""
echo "Usage: ./start_application.sh [OPTIONS]"
echo ""
echo "Options:"
echo " -s, --seed Seed the database with sample data"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " ./start_application.sh # Start the application"
echo " ./start_application.sh --seed # Seed database and start"
echo ""
exit 0
fi
# Check if Deno is installed
if ! command -v deno &> /dev/null; then
echo -e "${RED}✗ Deno is not installed!${NC}"
echo ""
echo -e "${YELLOW}Please install Deno first:${NC}"
echo -e "${GREEN} curl -fsSL https://deno.land/install.sh | sh${NC}"
echo ""
exit 1
fi
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Work Allocation System Startup${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Get the script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Check if MySQL database is running
echo -e "${YELLOW}Checking database connection...${NC}"
# Try to connect to MySQL using docker exec (most reliable for this setup)
check_db_connection() {
if command -v docker &> /dev/null; then
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
return $?
else
# Try using nc to check if port is open
nc -z localhost 3306 &> /dev/null
return $?
fi
}
# Retry logic for database connection
MAX_RETRIES=10
RETRY_DELAY=3
RETRY_COUNT=0
while ! check_db_connection; do
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo -e "${RED}✗ Database is not running after $MAX_RETRIES attempts!${NC}"
echo ""
echo -e "${YELLOW}Please start the database first by running:${NC}"
echo -e "${GREEN} docker-compose up${NC}"
echo ""
echo -e "${YELLOW}Or in detached mode:${NC}"
echo -e "${GREEN} docker-compose up -d${NC}"
echo ""
echo -e "Then run this script again."
exit 1
fi
echo -e "${YELLOW}⏳ Waiting for database... (attempt $RETRY_COUNT/$MAX_RETRIES)${NC}"
sleep $RETRY_DELAY
done
echo -e "${GREEN}✓ Database is running${NC}"
# Check if database is seeded
check_db_seeded() {
local result
if command -v docker &> /dev/null; then
result=$(docker exec work_allocation_db mysql -u root -padmin123 -N -e "SELECT COUNT(*) FROM work_allocation.users WHERE username='admin'" 2>/dev/null)
elif command -v mysql &> /dev/null; then
result=$(mysql -h localhost -P 3306 -u root -padmin123 -N -e "SELECT COUNT(*) FROM work_allocation.users WHERE username='admin'" 2>/dev/null)
fi
if [ "$result" = "1" ]; then
return 0 # Seeded
else
return 1 # Not seeded
fi
}
echo -e "${YELLOW}Checking if database is seeded...${NC}"
if check_db_seeded; then
echo -e "${GREEN}✓ Database is seeded${NC}"
DB_SEEDED=true
else
echo -e "${YELLOW}⚠ Database is NOT seeded${NC}"
DB_SEEDED=false
if [ "$SEED_DB" = false ]; then
echo ""
echo -e "${CYAN}The database has no data. You have two options:${NC}"
echo ""
echo -e " 1. Run with --seed flag to seed the database:"
echo -e " ${GREEN}./start_application.sh --seed${NC}"
echo ""
echo -e " 2. Or continue without seeding (you'll need to create users manually)"
echo ""
read -p "Do you want to seed the database now? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
SEED_DB=true
fi
fi
fi
echo ""
# Seed database if requested
if [ "$SEED_DB" = true ]; then
echo -e "${YELLOW}Seeding database...${NC}"
echo ""
# Run Deno seed script
cd "$SCRIPT_DIR/backend-deno"
deno task seed
SEED_STATUS=$?
if [ $SEED_STATUS -eq 0 ]; then
echo -e "${GREEN}✓ Database seeded successfully${NC}"
else
echo -e "${RED}✗ Database seeding failed${NC}"
exit 1
fi
echo ""
fi
# Check if frontend node_modules exist
if [ ! -d "$SCRIPT_DIR/node_modules" ]; then
echo -e "${YELLOW}Installing frontend dependencies...${NC}"
cd "$SCRIPT_DIR" && npm install
fi
# Function to cleanup on exit
cleanup() {
echo ""
echo -e "${YELLOW}Shutting down services...${NC}"
# Kill background processes
if [ ! -z "$BACKEND_PID" ]; then
kill $BACKEND_PID 2>/dev/null
echo -e "${GREEN}✓ Backend stopped${NC}"
fi
if [ ! -z "$FRONTEND_PID" ]; then
kill $FRONTEND_PID 2>/dev/null
echo -e "${GREEN}✓ Frontend stopped${NC}"
fi
echo -e "${BLUE}Goodbye!${NC}"
exit 0
}
# Set up trap to catch Ctrl+C
trap cleanup SIGINT SIGTERM
# Start Backend
echo -e "${YELLOW}Starting Deno Backend Server...${NC}"
cd "$SCRIPT_DIR/backend-deno"
deno task dev > "$SCRIPT_DIR/backend.log" 2>&1 &
BACKEND_PID=$!
# Wait a moment for backend to start
sleep 2
# Check if backend started successfully
if ps -p $BACKEND_PID > /dev/null 2>&1; then
echo -e "${GREEN}✓ Backend running on http://localhost:3000${NC}"
else
echo -e "${RED}✗ Backend failed to start. Check backend.log for details${NC}"
exit 1
fi
# Start Frontend
echo -e "${YELLOW}Starting Frontend Server...${NC}"
cd "$SCRIPT_DIR"
npm run dev > "$SCRIPT_DIR/frontend.log" 2>&1 &
FRONTEND_PID=$!
# Wait a moment for frontend to start
sleep 3
# Check if frontend started successfully
if ps -p $FRONTEND_PID > /dev/null 2>&1; then
echo -e "${GREEN}✓ Frontend running on http://localhost:5173${NC}"
else
echo -e "${RED}✗ Frontend failed to start. Check frontend.log for details${NC}"
kill $BACKEND_PID 2>/dev/null
exit 1
fi
echo ""
echo -e "${BLUE}========================================${NC}"
echo -e "${GREEN} Application Started Successfully!${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo -e " ${BLUE}Frontend:${NC} http://localhost:5173"
echo -e " ${BLUE}Backend:${NC} http://localhost:3000"
echo -e " ${BLUE}Database:${NC} localhost:3306"
echo -e " ${BLUE}Runtime:${NC} Deno (TypeScript)"
echo ""
echo -e " ${YELLOW}Logs:${NC}"
echo -e " Backend: $SCRIPT_DIR/backend.log"
echo -e " Frontend: $SCRIPT_DIR/frontend.log"
echo ""
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
echo ""
# Wait for processes
wait $BACKEND_PID $FRONTEND_PID

11
tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src"]
}

22
tsconfig.node.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

11
vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
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
},
})