(Feat): Initial Commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
168
QUICK_START.md
Normal 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
16
README.md
Normal 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
24
backend-deno/.env
Normal 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
210
backend-deno/README.md
Normal 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
|
||||||
79
backend-deno/config/database.ts
Normal file
79
backend-deno/config/database.ts
Normal 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;
|
||||||
40
backend-deno/config/env.ts
Normal file
40
backend-deno/config/env.ts
Normal 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
19
backend-deno/deno.json
Normal 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
163
backend-deno/deno.lock
generated
Normal 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
107
backend-deno/main.ts
Normal 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 });
|
||||||
102
backend-deno/middleware/auth.ts
Normal file
102
backend-deno/middleware/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
152
backend-deno/middleware/security.ts
Normal file
152
backend-deno/middleware/security.ts
Normal 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
|
||||||
293
backend-deno/routes/attendance.ts
Normal file
293
backend-deno/routes/attendance.ts
Normal 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
163
backend-deno/routes/auth.ts
Normal 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;
|
||||||
241
backend-deno/routes/contractor-rates.ts
Normal file
241
backend-deno/routes/contractor-rates.ts
Normal 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;
|
||||||
139
backend-deno/routes/departments.ts
Normal file
139
backend-deno/routes/departments.ts
Normal 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;
|
||||||
312
backend-deno/routes/users.ts
Normal file
312
backend-deno/routes/users.ts
Normal 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;
|
||||||
278
backend-deno/routes/work-allocations.ts
Normal file
278
backend-deno/routes/work-allocations.ts
Normal 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;
|
||||||
247
backend-deno/scripts/seed.ts
Normal file
247
backend-deno/scripts/seed.ts
Normal 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
170
backend-deno/types/index.ts
Normal 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
10
backend/.env
Normal 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
10
backend/.env.example
Normal 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
166
backend/README.md
Normal 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)
|
||||||
33
backend/config/database.js
Normal file
33
backend/config/database.js
Normal 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;
|
||||||
240
backend/database/database_seed.js
Normal file
240
backend/database/database_seed.js
Normal 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();
|
||||||
135
backend/database/init-schema.sql
Normal file
135
backend/database/init-schema.sql
Normal 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
|
||||||
@@ -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;
|
||||||
13
backend/database/migrations/add_contractor_rates_columns.sql
Normal file
13
backend/database/migrations/add_contractor_rates_columns.sql
Normal 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
134
backend/database/schema.sql
Normal 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');
|
||||||
72
backend/database/seed-admin.js
Normal file
72
backend/database/seed-admin.js
Normal 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();
|
||||||
32
backend/middleware/auth.js
Normal file
32
backend/middleware/auth.js
Normal 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
1120
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
backend/package.json
Normal file
20
backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
259
backend/routes/attendance.js
Normal file
259
backend/routes/attendance.js
Normal 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
114
backend/routes/auth.js
Normal 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;
|
||||||
198
backend/routes/contractor-rates.js
Normal file
198
backend/routes/contractor-rates.js
Normal 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;
|
||||||
96
backend/routes/departments.js
Normal file
96
backend/routes/departments.js
Normal 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
236
backend/routes/users.js
Normal 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;
|
||||||
244
backend/routes/work-allocations.js
Normal file
244
backend/routes/work-allocations.js
Normal 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;
|
||||||
13
backend/scripts/hash-password.js
Normal file
13
backend/scripts/hash-password.js
Normal 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
57
backend/server.js
Normal 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
30
docker-compose.yml
Normal 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
36
eslint.config.js
Normal 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
13
index.html
Normal 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
4156
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal 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
2660
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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
42
src/App.css
Normal 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
76
src/App.tsx
Normal 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
1
src/assets/react.svg
Normal 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 |
205
src/components/layout/Header.tsx
Normal file
205
src/components/layout/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
84
src/components/layout/Sidebar.tsx
Normal file
84
src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
src/components/ui/Button.tsx
Normal file
43
src/components/ui/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
src/components/ui/Card.tsx
Normal file
38
src/components/ui/Card.tsx
Normal 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>;
|
||||||
|
};
|
||||||
87
src/components/ui/Input.tsx
Normal file
87
src/components/ui/Input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
73
src/components/ui/Table.tsx
Normal file
73
src/components/ui/Table.tsx
Normal 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>;
|
||||||
|
};
|
||||||
91
src/contexts/AuthContext.tsx
Normal file
91
src/contexts/AuthContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
68
src/hooks/useDepartments.ts
Normal file
68
src/hooks/useDepartments.ts
Normal 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
84
src/hooks/useEmployees.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
84
src/hooks/useWorkAllocations.ts
Normal file
84
src/hooks/useWorkAllocations.ts
Normal 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
3
src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
357
src/pages/AttendancePage.tsx
Normal file
357
src/pages/AttendancePage.tsx
Normal 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
235
src/pages/DashboardPage.tsx
Normal 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
352
src/pages/LoginPage.tsx
Normal 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
410
src/pages/RatesPage.tsx
Normal 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
786
src/pages/UsersPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
516
src/pages/WorkAllocationPage.tsx
Normal file
516
src/pages/WorkAllocationPage.tsx
Normal 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
207
src/services/api.ts
Normal 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
58
src/types.ts
Normal 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
78
src/types/index.ts
Normal 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
258
start_application.sh
Executable 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
11
tailwind.config.js
Normal 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
25
tsconfig.json
Normal 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
22
tsconfig.node.json
Normal 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
11
vite.config.js
Normal 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
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user