From 87e9346d62916d0d3551a4de09f71a8f987a8b20 Mon Sep 17 00:00:00 2001 From: TenX PM Date: Mon, 4 May 2026 19:32:52 +0000 Subject: [PATCH] feat: complete HR portal full-stack application - NestJS backend with 11 modules: Auth, Employees, Departments, Attendance, Leaves, Payroll, Reimbursements, Announcements, Tax, Reports, Admin - JWT authentication with refresh tokens, role-based access (employee/hr_admin/super_admin) - MongoDB schemas with Mongoose for all entities - PDF payslip generation with pdfkit - OpenTelemetry tracing to SigNoz - Automatic database seeding on first startup - Next.js 14 frontend with App Router, Tailwind CSS - 25 pages covering all employee, HR admin, and super admin workflows - Multi-stage Dockerfile with nginx proxy - test-manifest.json for E2E testing Co-Authored-By: Claude Sonnet 4.6 --- app/.gitignore | 7 + app/Dockerfile | 44 +++ app/backend/nest-cli.json | 8 + app/backend/package.json | 47 +++ app/backend/src/admin/admin.controller.ts | 47 +++ app/backend/src/admin/admin.module.ts | 21 ++ app/backend/src/admin/admin.service.ts | 98 ++++++ .../src/admin/schemas/org-settings.schema.ts | 23 ++ .../announcements/announcements.controller.ts | 36 +++ .../src/announcements/announcements.module.ts | 15 + .../announcements/announcements.service.ts | 58 ++++ .../src/announcements/dto/announcement.dto.ts | 17 ++ .../schemas/announcement.schema.ts | 17 ++ app/backend/src/app.module.ts | 47 +++ .../src/attendance/attendance.controller.ts | 38 +++ .../src/attendance/attendance.module.ts | 15 + .../src/attendance/attendance.service.ts | 104 +++++++ .../src/attendance/dto/attendance.dto.ts | 12 + .../attendance/schemas/attendance.schema.ts | 17 ++ app/backend/src/auth/auth.controller.ts | 38 +++ app/backend/src/auth/auth.module.ts | 23 ++ app/backend/src/auth/auth.service.ts | 116 +++++++ app/backend/src/auth/dto/auth.dto.ts | 27 ++ .../src/auth/strategies/jwt.strategy.ts | 34 +++ .../decorators/current-user.decorator.ts | 9 + .../src/common/decorators/roles.decorator.ts | 6 + .../src/common/guards/jwt-auth.guard.ts | 12 + app/backend/src/common/guards/roles.guard.ts | 39 +++ .../src/departments/departments.controller.ts | 35 +++ .../src/departments/departments.module.ts | 15 + .../src/departments/departments.service.ts | 41 +++ .../src/departments/dto/department.dto.ts | 11 + .../departments/schemas/department.schema.ts | 13 + app/backend/src/employees/dto/employee.dto.ts | 53 ++++ .../src/employees/employees.controller.ts | 54 ++++ app/backend/src/employees/employees.module.ts | 15 + .../src/employees/employees.service.ts | 180 +++++++++++ .../src/employees/schemas/employee.schema.ts | 54 ++++ app/backend/src/leaves/dto/leave.dto.ts | 12 + app/backend/src/leaves/leaves.controller.ts | 41 +++ app/backend/src/leaves/leaves.module.ts | 19 ++ app/backend/src/leaves/leaves.service.ts | 167 +++++++++++ .../src/leaves/schemas/leave.schema.ts | 22 ++ app/backend/src/main.ts | 31 ++ app/backend/src/payroll/dto/payroll.dto.ts | 12 + app/backend/src/payroll/payroll.controller.ts | 41 +++ app/backend/src/payroll/payroll.module.ts | 24 ++ app/backend/src/payroll/payroll.service.ts | 283 ++++++++++++++++++ .../src/payroll/schemas/payroll.schema.ts | 54 ++++ .../reimbursements/dto/reimbursement.dto.ts | 15 + .../reimbursements.controller.ts | 68 +++++ .../reimbursements/reimbursements.module.ts | 15 + .../reimbursements/reimbursements.service.ts | 106 +++++++ .../schemas/reimbursement.schema.ts | 20 ++ app/backend/src/reports/reports.controller.ts | 32 ++ app/backend/src/reports/reports.module.ts | 22 ++ app/backend/src/reports/reports.service.ts | 147 +++++++++ app/backend/src/seed.service.ts | 274 +++++++++++++++++ app/backend/src/tax/dto/tax.dto.ts | 12 + app/backend/src/tax/schemas/tax.schema.ts | 39 +++ app/backend/src/tax/tax.controller.ts | 29 ++ app/backend/src/tax/tax.module.ts | 20 ++ app/backend/src/tax/tax.service.ts | 122 ++++++++ app/backend/src/tracing.ts | 22 ++ app/backend/tsconfig.json | 24 ++ app/docker-compose.yml | 60 ++++ .../app/(dashboard)/announcements/page.tsx | 54 ++++ .../app/(dashboard)/attendance/page.tsx | 188 ++++++++++++ .../app/(dashboard)/dashboard/page.tsx | 168 +++++++++++ app/frontend/app/(dashboard)/layout.tsx | 37 +++ app/frontend/app/(dashboard)/leave/page.tsx | 199 ++++++++++++ .../app/(dashboard)/payslips/page.tsx | 150 ++++++++++ app/frontend/app/(dashboard)/profile/page.tsx | 110 +++++++ .../app/(dashboard)/reimbursements/page.tsx | 164 ++++++++++ app/frontend/app/(dashboard)/tax/page.tsx | 157 ++++++++++ app/frontend/app/admin/announcements/page.tsx | 103 +++++++ app/frontend/app/admin/attendance/page.tsx | 149 +++++++++ app/frontend/app/admin/dashboard/page.tsx | 122 ++++++++ .../app/admin/employees/[id]/page.tsx | 190 ++++++++++++ app/frontend/app/admin/employees/new/page.tsx | 169 +++++++++++ app/frontend/app/admin/employees/page.tsx | 158 ++++++++++ app/frontend/app/admin/layout.tsx | 37 +++ app/frontend/app/admin/leaves/page.tsx | 173 +++++++++++ app/frontend/app/admin/payroll/page.tsx | 174 +++++++++++ .../app/admin/reimbursements/page.tsx | 137 +++++++++ app/frontend/app/admin/reports/page.tsx | 197 ++++++++++++ app/frontend/app/globals.css | 23 ++ app/frontend/app/layout.tsx | 25 ++ app/frontend/app/login/layout.tsx | 3 + app/frontend/app/login/page.tsx | 94 ++++++ app/frontend/app/page.tsx | 31 ++ app/frontend/app/superadmin/accounts/page.tsx | 118 ++++++++ .../app/superadmin/audit-log/page.tsx | 72 +++++ .../app/superadmin/dashboard/page.tsx | 85 ++++++ app/frontend/app/superadmin/layout.tsx | 32 ++ app/frontend/app/superadmin/settings/page.tsx | 94 ++++++ app/frontend/components/layout/Sidebar.tsx | 117 ++++++++ app/frontend/components/layout/Topbar.tsx | 27 ++ app/frontend/lib/api.ts | 180 +++++++++++ app/frontend/lib/auth-context.tsx | 85 ++++++ app/frontend/lib/utils.ts | 48 +++ app/frontend/next.config.js | 10 + app/frontend/package.json | 28 ++ app/frontend/postcss.config.js | 6 + app/frontend/tailwind.config.ts | 26 ++ app/frontend/tsconfig.json | 26 ++ app/nginx.conf | 51 ++++ app/start.sh | 22 ++ app/test-manifest.json | 201 +++++++++++++ 109 files changed, 7419 insertions(+) create mode 100644 app/.gitignore create mode 100644 app/Dockerfile create mode 100644 app/backend/nest-cli.json create mode 100644 app/backend/package.json create mode 100644 app/backend/src/admin/admin.controller.ts create mode 100644 app/backend/src/admin/admin.module.ts create mode 100644 app/backend/src/admin/admin.service.ts create mode 100644 app/backend/src/admin/schemas/org-settings.schema.ts create mode 100644 app/backend/src/announcements/announcements.controller.ts create mode 100644 app/backend/src/announcements/announcements.module.ts create mode 100644 app/backend/src/announcements/announcements.service.ts create mode 100644 app/backend/src/announcements/dto/announcement.dto.ts create mode 100644 app/backend/src/announcements/schemas/announcement.schema.ts create mode 100644 app/backend/src/app.module.ts create mode 100644 app/backend/src/attendance/attendance.controller.ts create mode 100644 app/backend/src/attendance/attendance.module.ts create mode 100644 app/backend/src/attendance/attendance.service.ts create mode 100644 app/backend/src/attendance/dto/attendance.dto.ts create mode 100644 app/backend/src/attendance/schemas/attendance.schema.ts create mode 100644 app/backend/src/auth/auth.controller.ts create mode 100644 app/backend/src/auth/auth.module.ts create mode 100644 app/backend/src/auth/auth.service.ts create mode 100644 app/backend/src/auth/dto/auth.dto.ts create mode 100644 app/backend/src/auth/strategies/jwt.strategy.ts create mode 100644 app/backend/src/common/decorators/current-user.decorator.ts create mode 100644 app/backend/src/common/decorators/roles.decorator.ts create mode 100644 app/backend/src/common/guards/jwt-auth.guard.ts create mode 100644 app/backend/src/common/guards/roles.guard.ts create mode 100644 app/backend/src/departments/departments.controller.ts create mode 100644 app/backend/src/departments/departments.module.ts create mode 100644 app/backend/src/departments/departments.service.ts create mode 100644 app/backend/src/departments/dto/department.dto.ts create mode 100644 app/backend/src/departments/schemas/department.schema.ts create mode 100644 app/backend/src/employees/dto/employee.dto.ts create mode 100644 app/backend/src/employees/employees.controller.ts create mode 100644 app/backend/src/employees/employees.module.ts create mode 100644 app/backend/src/employees/employees.service.ts create mode 100644 app/backend/src/employees/schemas/employee.schema.ts create mode 100644 app/backend/src/leaves/dto/leave.dto.ts create mode 100644 app/backend/src/leaves/leaves.controller.ts create mode 100644 app/backend/src/leaves/leaves.module.ts create mode 100644 app/backend/src/leaves/leaves.service.ts create mode 100644 app/backend/src/leaves/schemas/leave.schema.ts create mode 100644 app/backend/src/main.ts create mode 100644 app/backend/src/payroll/dto/payroll.dto.ts create mode 100644 app/backend/src/payroll/payroll.controller.ts create mode 100644 app/backend/src/payroll/payroll.module.ts create mode 100644 app/backend/src/payroll/payroll.service.ts create mode 100644 app/backend/src/payroll/schemas/payroll.schema.ts create mode 100644 app/backend/src/reimbursements/dto/reimbursement.dto.ts create mode 100644 app/backend/src/reimbursements/reimbursements.controller.ts create mode 100644 app/backend/src/reimbursements/reimbursements.module.ts create mode 100644 app/backend/src/reimbursements/reimbursements.service.ts create mode 100644 app/backend/src/reimbursements/schemas/reimbursement.schema.ts create mode 100644 app/backend/src/reports/reports.controller.ts create mode 100644 app/backend/src/reports/reports.module.ts create mode 100644 app/backend/src/reports/reports.service.ts create mode 100644 app/backend/src/seed.service.ts create mode 100644 app/backend/src/tax/dto/tax.dto.ts create mode 100644 app/backend/src/tax/schemas/tax.schema.ts create mode 100644 app/backend/src/tax/tax.controller.ts create mode 100644 app/backend/src/tax/tax.module.ts create mode 100644 app/backend/src/tax/tax.service.ts create mode 100644 app/backend/src/tracing.ts create mode 100644 app/backend/tsconfig.json create mode 100644 app/docker-compose.yml create mode 100644 app/frontend/app/(dashboard)/announcements/page.tsx create mode 100644 app/frontend/app/(dashboard)/attendance/page.tsx create mode 100644 app/frontend/app/(dashboard)/dashboard/page.tsx create mode 100644 app/frontend/app/(dashboard)/layout.tsx create mode 100644 app/frontend/app/(dashboard)/leave/page.tsx create mode 100644 app/frontend/app/(dashboard)/payslips/page.tsx create mode 100644 app/frontend/app/(dashboard)/profile/page.tsx create mode 100644 app/frontend/app/(dashboard)/reimbursements/page.tsx create mode 100644 app/frontend/app/(dashboard)/tax/page.tsx create mode 100644 app/frontend/app/admin/announcements/page.tsx create mode 100644 app/frontend/app/admin/attendance/page.tsx create mode 100644 app/frontend/app/admin/dashboard/page.tsx create mode 100644 app/frontend/app/admin/employees/[id]/page.tsx create mode 100644 app/frontend/app/admin/employees/new/page.tsx create mode 100644 app/frontend/app/admin/employees/page.tsx create mode 100644 app/frontend/app/admin/layout.tsx create mode 100644 app/frontend/app/admin/leaves/page.tsx create mode 100644 app/frontend/app/admin/payroll/page.tsx create mode 100644 app/frontend/app/admin/reimbursements/page.tsx create mode 100644 app/frontend/app/admin/reports/page.tsx create mode 100644 app/frontend/app/globals.css create mode 100644 app/frontend/app/layout.tsx create mode 100644 app/frontend/app/login/layout.tsx create mode 100644 app/frontend/app/login/page.tsx create mode 100644 app/frontend/app/page.tsx create mode 100644 app/frontend/app/superadmin/accounts/page.tsx create mode 100644 app/frontend/app/superadmin/audit-log/page.tsx create mode 100644 app/frontend/app/superadmin/dashboard/page.tsx create mode 100644 app/frontend/app/superadmin/layout.tsx create mode 100644 app/frontend/app/superadmin/settings/page.tsx create mode 100644 app/frontend/components/layout/Sidebar.tsx create mode 100644 app/frontend/components/layout/Topbar.tsx create mode 100644 app/frontend/lib/api.ts create mode 100644 app/frontend/lib/auth-context.tsx create mode 100644 app/frontend/lib/utils.ts create mode 100644 app/frontend/next.config.js create mode 100644 app/frontend/package.json create mode 100644 app/frontend/postcss.config.js create mode 100644 app/frontend/tailwind.config.ts create mode 100644 app/frontend/tsconfig.json create mode 100644 app/nginx.conf create mode 100644 app/start.sh create mode 100644 app/test-manifest.json diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..a98f032 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +dist/ +*.local +.env +.env.local +uploads/ diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..807e636 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,44 @@ +# Stage 1: Build backend +FROM node:20-alpine AS backend-builder +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm ci --legacy-peer-deps +COPY backend/ . +RUN npm run build + +# Stage 2: Build frontend +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci --legacy-peer-deps +COPY frontend/ . +ENV NEXT_PUBLIC_API_URL=/api-backend +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# Stage 3: Runtime +FROM node:20-alpine AS runner +WORKDIR /app + +# Install nginx +RUN apk add --no-cache nginx + +# Copy backend build +COPY --from=backend-builder /app/backend/dist ./backend/dist +COPY --from=backend-builder /app/backend/node_modules ./backend/node_modules +COPY --from=backend-builder /app/backend/package.json ./backend/ + +# Copy frontend build (standalone mode) +COPY --from=frontend-builder /app/frontend/.next/standalone ./frontend/ +COPY --from=frontend-builder /app/frontend/.next/static ./frontend/.next/static +COPY --from=frontend-builder /app/frontend/public ./frontend/public + +# Nginx config +COPY nginx.conf /etc/nginx/nginx.conf + +# Start script +COPY start.sh . +RUN chmod +x start.sh + +EXPOSE 80 +CMD ["./start.sh"] diff --git a/app/backend/nest-cli.json b/app/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/app/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/app/backend/package.json b/app/backend/package.json new file mode 100644 index 0000000..72e54b3 --- /dev/null +++ b/app/backend/package.json @@ -0,0 +1,47 @@ +{ + "name": "hr-portal-backend", + "version": "1.0.0", + "description": "HR Portal Backend API", + "main": "dist/main.js", + "scripts": { + "build": "nest build", + "start": "node dist/main.js", + "start:dev": "nest start --watch", + "seed": "ts-node src/seed.ts" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.0.0", + "@nestjs/mongoose": "^10.0.0", + "@nestjs/passport": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@opentelemetry/api": "^1.7.0", + "@opentelemetry/auto-instrumentations-node": "^0.40.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.46.0", + "@opentelemetry/resources": "^1.19.0", + "@opentelemetry/sdk-node": "^0.46.0", + "@opentelemetry/semantic-conventions": "^1.19.0", + "bcryptjs": "^2.4.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "csv-parse": "^5.5.3", + "mongoose": "^7.6.0", + "multer": "^1.4.5-lts.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pdfkit": "^0.18.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@types/bcryptjs": "^2.4.6", + "@types/multer": "^1.4.11", + "@types/node": "^20.0.0", + "@types/passport-jwt": "^3.0.13", + "ts-node": "^10.9.1", + "typescript": "^5.1.3" + } +} diff --git a/app/backend/src/admin/admin.controller.ts b/app/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000..dafafa5 --- /dev/null +++ b/app/backend/src/admin/admin.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { AdminService } from './admin.service'; + +@Controller() +@UseGuards(JwtAuthGuard, RolesGuard) +export class AdminController { + constructor(private adminService: AdminService) {} + + @Get('admin/accounts') + @Roles('super_admin') + getAdminAccounts() { + return this.adminService.getAdminAccounts(); + } + + @Post('admin/accounts') + @Roles('super_admin') + createAdminAccount(@Body() dto: any) { + return this.adminService.createAdminAccount(dto); + } + + @Put('admin/accounts/:id/deactivate') + @Roles('super_admin') + deactivateAccount(@Param('id') id: string) { + return this.adminService.deactivateAccount(id); + } + + @Get('audit-logs') + @Roles('super_admin') + getAuditLogs(@Query() query: any) { + return this.adminService.getAuditLogs(query); + } + + @Get('org/settings') + @Roles('hr_admin') + getOrgSettings() { + return this.adminService.getOrgSettings(); + } + + @Put('org/settings') + @Roles('super_admin') + updateOrgSettings(@Body() dto: any) { + return this.adminService.updateOrgSettings(dto); + } +} diff --git a/app/backend/src/admin/admin.module.ts b/app/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..dbc71e3 --- /dev/null +++ b/app/backend/src/admin/admin.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { Employee, EmployeeSchema } from '../employees/schemas/employee.schema'; +import { AuditLog, AuditLogSchema } from '../tax/schemas/tax.schema'; +import { OrgSettings, OrgSettingsSchema } from './schemas/org-settings.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: Employee.name, schema: EmployeeSchema }, + { name: AuditLog.name, schema: AuditLogSchema }, + { name: OrgSettings.name, schema: OrgSettingsSchema }, + ]), + ], + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/app/backend/src/admin/admin.service.ts b/app/backend/src/admin/admin.service.ts new file mode 100644 index 0000000..a782249 --- /dev/null +++ b/app/backend/src/admin/admin.service.ts @@ -0,0 +1,98 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import * as bcrypt from 'bcryptjs'; +import { Employee, EmployeeDocument } from '../employees/schemas/employee.schema'; +import { AuditLog, AuditLogDocument } from '../tax/schemas/tax.schema'; +import { OrgSettings, OrgSettingsDocument } from './schemas/org-settings.schema'; + +@Injectable() +export class AdminService { + constructor( + @InjectModel(Employee.name) private employeeModel: Model, + @InjectModel(AuditLog.name) private auditLogModel: Model, + @InjectModel(OrgSettings.name) private orgSettingsModel: Model, + ) {} + + async getAdminAccounts() { + return this.employeeModel + .find({ role: { $in: ['hr_admin', 'super_admin'] } }) + .select('-passwordHash -refreshToken') + .populate('department', 'name') + .lean(); + } + + async createAdminAccount(dto: any) { + const passwordHash = await bcrypt.hash(dto.password || 'HrAdmin@123', 12); + const employee = new this.employeeModel({ + ...dto, + role: 'hr_admin', + passwordHash, + mustChangePassword: true, + department: dto.department ? new Types.ObjectId(dto.department) : undefined, + }); + const saved = await employee.save(); + const result = saved.toObject(); + delete result.passwordHash; + delete result.refreshToken; + return result; + } + + async deactivateAccount(id: string) { + const employee = await this.employeeModel.findByIdAndUpdate( + id, + { isActive: false }, + { new: true }, + ); + if (!employee) throw new NotFoundException('Account not found'); + return { message: 'Account deactivated' }; + } + + async getAuditLogs(query: any) { + const page = parseInt(query.page) || 1; + const limit = parseInt(query.limit) || 20; + + const [data, total] = await Promise.all([ + this.auditLogModel + .find() + .populate('performedBy', 'firstName lastName employeeId') + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(limit) + .lean(), + this.auditLogModel.countDocuments(), + ]); + + return { data, total, page, limit }; + } + + async getOrgSettings() { + let settings = await this.orgSettingsModel.findOne().lean(); + if (!settings) { + const newSettings = new this.orgSettingsModel({}); + await newSettings.save(); + settings = newSettings.toObject(); + } + return settings; + } + + async updateOrgSettings(dto: any) { + const settings = await this.orgSettingsModel.findOneAndUpdate( + {}, + dto, + { upsert: true, new: true }, + ); + return settings; + } + + async logAction(action: string, entity: string, entityId: string, performedBy: string, details?: any) { + const log = new this.auditLogModel({ + action, + entity, + entityId, + performedBy: performedBy ? new Types.ObjectId(performedBy) : undefined, + details, + }); + await log.save(); + } +} diff --git a/app/backend/src/admin/schemas/org-settings.schema.ts b/app/backend/src/admin/schemas/org-settings.schema.ts new file mode 100644 index 0000000..e50cbc5 --- /dev/null +++ b/app/backend/src/admin/schemas/org-settings.schema.ts @@ -0,0 +1,23 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type OrgSettingsDocument = OrgSettings & Document; + +@Schema({ _id: false }) +class LeavePolicies { + @Prop({ default: 12 }) casualLeavePerYear: number; + @Prop({ default: 12 }) sickLeavePerYear: number; + @Prop({ default: 15 }) earnedLeavePerYear: number; +} + +@Schema({ timestamps: true }) +export class OrgSettings { + @Prop({ default: 'HR Portal' }) companyName: string; + @Prop() companyAddress: string; + @Prop() companyCIN: string; + @Prop() companyGST: string; + @Prop({ type: LeavePolicies, default: {} }) leavePolicies: LeavePolicies; + @Prop({ default: 'Karnataka' }) state: string; +} + +export const OrgSettingsSchema = SchemaFactory.createForClass(OrgSettings); diff --git a/app/backend/src/announcements/announcements.controller.ts b/app/backend/src/announcements/announcements.controller.ts new file mode 100644 index 0000000..02b75d7 --- /dev/null +++ b/app/backend/src/announcements/announcements.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AnnouncementsService } from './announcements.service'; +import { CreateAnnouncementDto, UpdateAnnouncementDto } from './dto/announcement.dto'; + +@Controller('announcements') +@UseGuards(JwtAuthGuard, RolesGuard) +export class AnnouncementsController { + constructor(private announcementsService: AnnouncementsService) {} + + @Get() + findAll(@CurrentUser() user: any) { + return this.announcementsService.findAll(user); + } + + @Post() + @Roles('hr_admin') + create(@Body() dto: CreateAnnouncementDto, @CurrentUser() user: any) { + return this.announcementsService.create(dto, user._id.toString()); + } + + @Put(':id') + @Roles('hr_admin') + update(@Param('id') id: string, @Body() dto: UpdateAnnouncementDto) { + return this.announcementsService.update(id, dto); + } + + @Delete(':id') + @Roles('hr_admin') + remove(@Param('id') id: string) { + return this.announcementsService.remove(id); + } +} diff --git a/app/backend/src/announcements/announcements.module.ts b/app/backend/src/announcements/announcements.module.ts new file mode 100644 index 0000000..29940d0 --- /dev/null +++ b/app/backend/src/announcements/announcements.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { AnnouncementsController } from './announcements.controller'; +import { AnnouncementsService } from './announcements.service'; +import { Announcement, AnnouncementSchema } from './schemas/announcement.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Announcement.name, schema: AnnouncementSchema }]), + ], + controllers: [AnnouncementsController], + providers: [AnnouncementsService], + exports: [AnnouncementsService, MongooseModule], +}) +export class AnnouncementsModule {} diff --git a/app/backend/src/announcements/announcements.service.ts b/app/backend/src/announcements/announcements.service.ts new file mode 100644 index 0000000..1798a92 --- /dev/null +++ b/app/backend/src/announcements/announcements.service.ts @@ -0,0 +1,58 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { Announcement, AnnouncementDocument } from './schemas/announcement.schema'; +import { CreateAnnouncementDto, UpdateAnnouncementDto } from './dto/announcement.dto'; + +@Injectable() +export class AnnouncementsService { + constructor( + @InjectModel(Announcement.name) + private announcementModel: Model, + ) {} + + async findAll(currentUser: any) { + const filter: any = { isActive: true }; + const now = new Date(); + filter.$or = [ + { expiresAt: { $exists: false } }, + { expiresAt: null }, + { expiresAt: { $gte: now } }, + ]; + return this.announcementModel + .find(filter) + .populate('createdBy', 'firstName lastName') + .populate('targetDepartment', 'name') + .sort({ createdAt: -1 }) + .lean(); + } + + async create(dto: CreateAnnouncementDto, createdBy: string) { + const announcement = new this.announcementModel({ + ...dto, + targetDepartment: dto.targetDepartment ? new Types.ObjectId(dto.targetDepartment) : undefined, + createdBy: new Types.ObjectId(createdBy), + }); + return announcement.save(); + } + + async update(id: string, dto: UpdateAnnouncementDto) { + const update: any = { ...dto }; + if (dto.targetDepartment) { + update.targetDepartment = new Types.ObjectId(dto.targetDepartment); + } + const announcement = await this.announcementModel.findByIdAndUpdate(id, update, { new: true }); + if (!announcement) throw new NotFoundException('Announcement not found'); + return announcement; + } + + async remove(id: string) { + const announcement = await this.announcementModel.findByIdAndUpdate( + id, + { isActive: false }, + { new: true }, + ); + if (!announcement) throw new NotFoundException('Announcement not found'); + return { message: 'Announcement deleted' }; + } +} diff --git a/app/backend/src/announcements/dto/announcement.dto.ts b/app/backend/src/announcements/dto/announcement.dto.ts new file mode 100644 index 0000000..a24f499 --- /dev/null +++ b/app/backend/src/announcements/dto/announcement.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsEnum, IsOptional, IsDateString } from 'class-validator'; + +export class CreateAnnouncementDto { + @IsString() title: string; + @IsString() content: string; + @IsOptional() @IsEnum(['all', 'department']) targetAudience?: string; + @IsOptional() @IsString() targetDepartment?: string; + @IsOptional() @IsDateString() expiresAt?: string; +} + +export class UpdateAnnouncementDto { + @IsOptional() @IsString() title?: string; + @IsOptional() @IsString() content?: string; + @IsOptional() @IsEnum(['all', 'department']) targetAudience?: string; + @IsOptional() @IsString() targetDepartment?: string; + @IsOptional() @IsDateString() expiresAt?: string; +} diff --git a/app/backend/src/announcements/schemas/announcement.schema.ts b/app/backend/src/announcements/schemas/announcement.schema.ts new file mode 100644 index 0000000..a873c00 --- /dev/null +++ b/app/backend/src/announcements/schemas/announcement.schema.ts @@ -0,0 +1,17 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type AnnouncementDocument = Announcement & Document; + +@Schema({ timestamps: true }) +export class Announcement { + @Prop({ required: true }) title: string; + @Prop({ required: true }) content: string; + @Prop({ enum: ['all', 'department'], default: 'all' }) targetAudience: string; + @Prop({ type: Types.ObjectId, ref: 'Department' }) targetDepartment: Types.ObjectId; + @Prop({ type: Types.ObjectId, ref: 'Employee' }) createdBy: Types.ObjectId; + @Prop({ default: true }) isActive: boolean; + @Prop() expiresAt: Date; +} + +export const AnnouncementSchema = SchemaFactory.createForClass(Announcement); diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts new file mode 100644 index 0000000..e288576 --- /dev/null +++ b/app/backend/src/app.module.ts @@ -0,0 +1,47 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { AuthModule } from './auth/auth.module'; +import { EmployeesModule } from './employees/employees.module'; +import { DepartmentsModule } from './departments/departments.module'; +import { AttendanceModule } from './attendance/attendance.module'; +import { LeavesModule } from './leaves/leaves.module'; +import { PayrollModule } from './payroll/payroll.module'; +import { ReimbursementsModule } from './reimbursements/reimbursements.module'; +import { AnnouncementsModule } from './announcements/announcements.module'; +import { TaxModule } from './tax/tax.module'; +import { ReportsModule } from './reports/reports.module'; +import { AdminModule } from './admin/admin.module'; +import { SeedService } from './seed.service'; +import { Employee, EmployeeSchema } from './employees/schemas/employee.schema'; +import { Department, DepartmentSchema } from './departments/schemas/department.schema'; +import { Announcement, AnnouncementSchema } from './announcements/schemas/announcement.schema'; +import { Attendance, AttendanceSchema } from './attendance/schemas/attendance.schema'; +import { Leave, LeaveSchema } from './leaves/schemas/leave.schema'; + +@Module({ + imports: [ + MongooseModule.forRoot( + process.env.MONGODB_URI || 'mongodb://localhost:27017/hr_portal', + ), + MongooseModule.forFeature([ + { name: Employee.name, schema: EmployeeSchema }, + { name: Department.name, schema: DepartmentSchema }, + { name: Announcement.name, schema: AnnouncementSchema }, + { name: Attendance.name, schema: AttendanceSchema }, + { name: Leave.name, schema: LeaveSchema }, + ]), + AuthModule, + EmployeesModule, + DepartmentsModule, + AttendanceModule, + LeavesModule, + PayrollModule, + ReimbursementsModule, + AnnouncementsModule, + TaxModule, + ReportsModule, + AdminModule, + ], + providers: [SeedService], +}) +export class AppModule {} diff --git a/app/backend/src/attendance/attendance.controller.ts b/app/backend/src/attendance/attendance.controller.ts new file mode 100644 index 0000000..fd07fc8 --- /dev/null +++ b/app/backend/src/attendance/attendance.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AttendanceService } from './attendance.service'; +import { MarkAttendanceDto, UpdateAttendanceDto } from './dto/attendance.dto'; + +@Controller('attendance') +@UseGuards(JwtAuthGuard, RolesGuard) +export class AttendanceController { + constructor(private attendanceService: AttendanceService) {} + + @Post() + markAttendance(@CurrentUser() user: any, @Body() dto: MarkAttendanceDto) { + return this.attendanceService.markAttendance(user._id.toString(), dto); + } + + @Put(':id') + @Roles('hr_admin') + updateAttendance(@Param('id') id: string, @Body() dto: UpdateAttendanceDto) { + return this.attendanceService.updateAttendance(id, dto); + } + + @Get() + getAttendance(@Query() query: any, @CurrentUser() user: any) { + if (user.role === 'employee') { + query.employeeId = user._id.toString(); + } + return this.attendanceService.getAttendance(query); + } + + @Post('bulk') + @Roles('hr_admin') + bulkMark(@Body() body: { employeeId: string; records: any[] }) { + return this.attendanceService.bulkMarkForHr(body.employeeId, body.records); + } +} diff --git a/app/backend/src/attendance/attendance.module.ts b/app/backend/src/attendance/attendance.module.ts new file mode 100644 index 0000000..14bbf91 --- /dev/null +++ b/app/backend/src/attendance/attendance.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { AttendanceController } from './attendance.controller'; +import { AttendanceService } from './attendance.service'; +import { Attendance, AttendanceSchema } from './schemas/attendance.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Attendance.name, schema: AttendanceSchema }]), + ], + controllers: [AttendanceController], + providers: [AttendanceService], + exports: [AttendanceService, MongooseModule], +}) +export class AttendanceModule {} diff --git a/app/backend/src/attendance/attendance.service.ts b/app/backend/src/attendance/attendance.service.ts new file mode 100644 index 0000000..f136ea7 --- /dev/null +++ b/app/backend/src/attendance/attendance.service.ts @@ -0,0 +1,104 @@ +import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { Attendance, AttendanceDocument } from './schemas/attendance.schema'; +import { MarkAttendanceDto, UpdateAttendanceDto } from './dto/attendance.dto'; + +@Injectable() +export class AttendanceService { + constructor( + @InjectModel(Attendance.name) private attendanceModel: Model, + ) {} + + async markAttendance(employeeId: string, dto: MarkAttendanceDto) { + const now = new Date(); + const date = dto.date ? new Date(dto.date) : new Date(); + date.setHours(0, 0, 0, 0); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (date.getTime() !== today.getTime()) { + throw new BadRequestException('Can only mark attendance for today'); + } + + if (now.getHours() >= 23) { + throw new BadRequestException('Cannot mark attendance after 11 PM'); + } + + const existing = await this.attendanceModel.findOne({ + employeeId: new Types.ObjectId(employeeId), + date, + }); + + if (existing) { + throw new ConflictException('Attendance already marked for today'); + } + + const attendance = new this.attendanceModel({ + employeeId: new Types.ObjectId(employeeId), + date, + status: dto.status || 'present', + markedBy: 'self', + markedAt: now, + notes: dto.notes, + }); + + return attendance.save(); + } + + async updateAttendance(id: string, dto: UpdateAttendanceDto) { + const attendance = await this.attendanceModel.findByIdAndUpdate( + id, + { ...dto, markedBy: 'admin' }, + { new: true }, + ); + if (!attendance) throw new NotFoundException('Attendance record not found'); + return attendance; + } + + async getAttendance(query: any) { + const filter: any = {}; + + if (query.employeeId) { + filter.employeeId = new Types.ObjectId(query.employeeId); + } + + if (query.month && query.year) { + const month = parseInt(query.month); + const year = parseInt(query.year); + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0, 23, 59, 59); + filter.date = { $gte: startDate, $lte: endDate }; + } + + return this.attendanceModel + .find(filter) + .populate('employeeId', 'firstName lastName employeeId') + .sort({ date: 1 }) + .lean(); + } + + async bulkMarkForHr(employeeId: string, records: any[]) { + const results = []; + for (const record of records) { + const date = new Date(record.date); + date.setHours(0, 0, 0, 0); + + await this.attendanceModel.findOneAndUpdate( + { employeeId: new Types.ObjectId(employeeId), date }, + { + employeeId: new Types.ObjectId(employeeId), + date, + status: record.status, + markedBy: 'admin', + markedAt: new Date(), + notes: record.notes, + }, + { upsert: true, new: true }, + ); + results.push({ date: record.date, status: record.status }); + } + return results; + } +} diff --git a/app/backend/src/attendance/dto/attendance.dto.ts b/app/backend/src/attendance/dto/attendance.dto.ts new file mode 100644 index 0000000..9114882 --- /dev/null +++ b/app/backend/src/attendance/dto/attendance.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsOptional, IsEnum, IsDateString } from 'class-validator'; + +export class MarkAttendanceDto { + @IsOptional() @IsDateString() date?: string; + @IsOptional() @IsEnum(['present', 'wfh', 'half_day']) status?: string; + @IsOptional() @IsString() notes?: string; +} + +export class UpdateAttendanceDto { + @IsOptional() @IsEnum(['present', 'absent', 'wfh', 'half_day', 'holiday']) status?: string; + @IsOptional() @IsString() notes?: string; +} diff --git a/app/backend/src/attendance/schemas/attendance.schema.ts b/app/backend/src/attendance/schemas/attendance.schema.ts new file mode 100644 index 0000000..1871f48 --- /dev/null +++ b/app/backend/src/attendance/schemas/attendance.schema.ts @@ -0,0 +1,17 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type AttendanceDocument = Attendance & Document; + +@Schema({ timestamps: true }) +export class Attendance { + @Prop({ type: Types.ObjectId, ref: 'Employee', required: true }) employeeId: Types.ObjectId; + @Prop({ required: true }) date: Date; + @Prop({ enum: ['present', 'absent', 'wfh', 'half_day', 'holiday'], default: 'present' }) status: string; + @Prop({ enum: ['self', 'admin'], default: 'self' }) markedBy: string; + @Prop() markedAt: Date; + @Prop() notes: string; +} + +export const AttendanceSchema = SchemaFactory.createForClass(Attendance); +AttendanceSchema.index({ employeeId: 1, date: 1 }, { unique: true }); diff --git a/app/backend/src/auth/auth.controller.ts b/app/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..0f8022a --- /dev/null +++ b/app/backend/src/auth/auth.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Post, Body, UseGuards, Get } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { LoginDto, RefreshTokenDto, ChangePasswordDto } from './dto/auth.dto'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; + +@Controller('auth') +export class AuthController { + constructor(private authService: AuthService) {} + + @Post('login') + async login(@Body() loginDto: LoginDto) { + return this.authService.login(loginDto); + } + + @Post('refresh') + async refresh(@Body() dto: RefreshTokenDto) { + return this.authService.refresh(dto.refreshToken); + } + + @Post('logout') + @UseGuards(JwtAuthGuard) + async logout(@CurrentUser() user: any) { + return this.authService.logout(user._id.toString()); + } + + @Post('change-password') + @UseGuards(JwtAuthGuard) + async changePassword(@CurrentUser() user: any, @Body() dto: ChangePasswordDto) { + return this.authService.changePassword(user._id.toString(), dto); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + async getMe(@CurrentUser() user: any) { + return user; + } +} diff --git a/app/backend/src/auth/auth.module.ts b/app/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..084a9c0 --- /dev/null +++ b/app/backend/src/auth/auth.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { MongooseModule } from '@nestjs/mongoose'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { Employee, EmployeeSchema } from '../employees/schemas/employee.schema'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({ + secret: process.env.JWT_SECRET || 'hr-portal-jwt-secret-2024', + signOptions: { expiresIn: '15m' }, + }), + MongooseModule.forFeature([{ name: Employee.name, schema: EmployeeSchema }]), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [JwtModule, PassportModule], +}) +export class AuthModule {} diff --git a/app/backend/src/auth/auth.service.ts b/app/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..ca57b25 --- /dev/null +++ b/app/backend/src/auth/auth.service.ts @@ -0,0 +1,116 @@ +import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import * as bcrypt from 'bcryptjs'; +import { Employee, EmployeeDocument } from '../employees/schemas/employee.schema'; +import { LoginDto, ChangePasswordDto } from './dto/auth.dto'; + +@Injectable() +export class AuthService { + constructor( + @InjectModel(Employee.name) private employeeModel: Model, + private jwtService: JwtService, + ) {} + + async login(loginDto: LoginDto) { + const employee = await this.employeeModel.findOne({ + employeeId: loginDto.employeeId, + isActive: true, + }); + + if (!employee) { + throw new UnauthorizedException('Invalid credentials'); + } + + const isPasswordValid = await bcrypt.compare(loginDto.password, employee.passwordHash); + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid credentials'); + } + + const payload = { + sub: employee._id, + employeeId: employee.employeeId, + role: employee.role, + }; + + const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' }); + const refreshToken = this.jwtService.sign(payload, { + secret: process.env.JWT_REFRESH_SECRET || 'hr-portal-refresh-secret-2024', + expiresIn: '7d', + }); + + const hashedRefreshToken = await bcrypt.hash(refreshToken, 10); + await this.employeeModel.findByIdAndUpdate(employee._id, { + refreshToken: hashedRefreshToken, + }); + + return { + accessToken, + refreshToken, + user: { + _id: employee._id, + employeeId: employee.employeeId, + firstName: employee.firstName, + lastName: employee.lastName, + email: employee.email, + role: employee.role, + mustChangePassword: employee.mustChangePassword, + }, + }; + } + + async refresh(refreshToken: string) { + try { + const payload = this.jwtService.verify(refreshToken, { + secret: process.env.JWT_REFRESH_SECRET || 'hr-portal-refresh-secret-2024', + }); + + const employee = await this.employeeModel.findById(payload.sub); + if (!employee || !employee.refreshToken) { + throw new UnauthorizedException('Invalid refresh token'); + } + + const isValid = await bcrypt.compare(refreshToken, employee.refreshToken); + if (!isValid) { + throw new UnauthorizedException('Invalid refresh token'); + } + + const newPayload = { + sub: employee._id, + employeeId: employee.employeeId, + role: employee.role, + }; + + const accessToken = this.jwtService.sign(newPayload, { expiresIn: '15m' }); + return { accessToken }; + } catch (error) { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + async logout(userId: string) { + await this.employeeModel.findByIdAndUpdate(userId, { refreshToken: null }); + return { message: 'Logged out successfully' }; + } + + async changePassword(userId: string, dto: ChangePasswordDto) { + const employee = await this.employeeModel.findById(userId); + if (!employee) { + throw new UnauthorizedException('User not found'); + } + + const isPasswordValid = await bcrypt.compare(dto.currentPassword, employee.passwordHash); + if (!isPasswordValid) { + throw new BadRequestException('Current password is incorrect'); + } + + const newPasswordHash = await bcrypt.hash(dto.newPassword, 12); + await this.employeeModel.findByIdAndUpdate(userId, { + passwordHash: newPasswordHash, + mustChangePassword: false, + }); + + return { message: 'Password changed successfully' }; + } +} diff --git a/app/backend/src/auth/dto/auth.dto.ts b/app/backend/src/auth/dto/auth.dto.ts new file mode 100644 index 0000000..c3e3c22 --- /dev/null +++ b/app/backend/src/auth/dto/auth.dto.ts @@ -0,0 +1,27 @@ +import { IsString, IsNotEmpty, MinLength } from 'class-validator'; + +export class LoginDto { + @IsString() + @IsNotEmpty() + employeeId: string; + + @IsString() + @IsNotEmpty() + password: string; +} + +export class RefreshTokenDto { + @IsString() + @IsNotEmpty() + refreshToken: string; +} + +export class ChangePasswordDto { + @IsString() + @IsNotEmpty() + currentPassword: string; + + @IsString() + @MinLength(6) + newPassword: string; +} diff --git a/app/backend/src/auth/strategies/jwt.strategy.ts b/app/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..150d89d --- /dev/null +++ b/app/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,34 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Employee, EmployeeDocument } from '../../employees/schemas/employee.schema'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + @InjectModel(Employee.name) private employeeModel: Model, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET || 'hr-portal-jwt-secret-2024', + }); + } + + async validate(payload: any) { + const employee = await this.employeeModel.findById(payload.sub).lean(); + if (!employee || !employee.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + return { + _id: employee._id, + employeeId: employee.employeeId, + email: employee.email, + role: employee.role, + firstName: employee.firstName, + lastName: employee.lastName, + }; + } +} diff --git a/app/backend/src/common/decorators/current-user.decorator.ts b/app/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..de752b6 --- /dev/null +++ b/app/backend/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator( + (data: string, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + return data ? user?.[data] : user; + }, +); diff --git a/app/backend/src/common/decorators/roles.decorator.ts b/app/backend/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..96063dd --- /dev/null +++ b/app/backend/src/common/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export type Role = 'employee' | 'hr_admin' | 'super_admin'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/app/backend/src/common/guards/jwt-auth.guard.ts b/app/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..598e697 --- /dev/null +++ b/app/backend/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,12 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + throw err || new UnauthorizedException('Invalid or expired token'); + } + return user; + } +} diff --git a/app/backend/src/common/guards/roles.guard.ts b/app/backend/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..0ee3e9d --- /dev/null +++ b/app/backend/src/common/guards/roles.guard.ts @@ -0,0 +1,39 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY, Role } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + if (!user) { + throw new ForbiddenException('Access denied'); + } + + const roleHierarchy: Record = { + employee: 1, + hr_admin: 2, + super_admin: 3, + }; + + const userLevel = roleHierarchy[user.role as Role] || 0; + const hasRole = requiredRoles.some((role) => userLevel >= roleHierarchy[role]); + + if (!hasRole) { + throw new ForbiddenException('Insufficient permissions'); + } + + return true; + } +} diff --git a/app/backend/src/departments/departments.controller.ts b/app/backend/src/departments/departments.controller.ts new file mode 100644 index 0000000..a84cf8d --- /dev/null +++ b/app/backend/src/departments/departments.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { DepartmentsService } from './departments.service'; +import { CreateDepartmentDto, UpdateDepartmentDto } from './dto/department.dto'; + +@Controller('departments') +@UseGuards(JwtAuthGuard, RolesGuard) +export class DepartmentsController { + constructor(private departmentsService: DepartmentsService) {} + + @Get() + findAll() { + return this.departmentsService.findAll(); + } + + @Post() + @Roles('hr_admin') + create(@Body() dto: CreateDepartmentDto) { + return this.departmentsService.create(dto); + } + + @Put(':id') + @Roles('hr_admin') + update(@Param('id') id: string, @Body() dto: UpdateDepartmentDto) { + return this.departmentsService.update(id, dto); + } + + @Delete(':id') + @Roles('hr_admin') + remove(@Param('id') id: string) { + return this.departmentsService.remove(id); + } +} diff --git a/app/backend/src/departments/departments.module.ts b/app/backend/src/departments/departments.module.ts new file mode 100644 index 0000000..9358543 --- /dev/null +++ b/app/backend/src/departments/departments.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { DepartmentsController } from './departments.controller'; +import { DepartmentsService } from './departments.service'; +import { Department, DepartmentSchema } from './schemas/department.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Department.name, schema: DepartmentSchema }]), + ], + controllers: [DepartmentsController], + providers: [DepartmentsService], + exports: [DepartmentsService, MongooseModule], +}) +export class DepartmentsModule {} diff --git a/app/backend/src/departments/departments.service.ts b/app/backend/src/departments/departments.service.ts new file mode 100644 index 0000000..13f640d --- /dev/null +++ b/app/backend/src/departments/departments.service.ts @@ -0,0 +1,41 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Department, DepartmentDocument } from './schemas/department.schema'; +import { CreateDepartmentDto, UpdateDepartmentDto } from './dto/department.dto'; + +@Injectable() +export class DepartmentsService { + constructor( + @InjectModel(Department.name) private departmentModel: Model, + ) {} + + async findAll() { + return this.departmentModel.find({ isActive: true }).sort({ name: 1 }).lean(); + } + + async create(dto: CreateDepartmentDto) { + const existing = await this.departmentModel.findOne({ name: dto.name }); + if (existing) { + throw new ConflictException('Department already exists'); + } + const dept = new this.departmentModel(dto); + return dept.save(); + } + + async update(id: string, dto: UpdateDepartmentDto) { + const dept = await this.departmentModel.findByIdAndUpdate(id, dto, { new: true }); + if (!dept) throw new NotFoundException('Department not found'); + return dept; + } + + async remove(id: string) { + const dept = await this.departmentModel.findByIdAndUpdate( + id, + { isActive: false }, + { new: true }, + ); + if (!dept) throw new NotFoundException('Department not found'); + return { message: 'Department deleted' }; + } +} diff --git a/app/backend/src/departments/dto/department.dto.ts b/app/backend/src/departments/dto/department.dto.ts new file mode 100644 index 0000000..c24606b --- /dev/null +++ b/app/backend/src/departments/dto/department.dto.ts @@ -0,0 +1,11 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreateDepartmentDto { + @IsString() name: string; + @IsOptional() @IsString() description?: string; +} + +export class UpdateDepartmentDto { + @IsOptional() @IsString() name?: string; + @IsOptional() @IsString() description?: string; +} diff --git a/app/backend/src/departments/schemas/department.schema.ts b/app/backend/src/departments/schemas/department.schema.ts new file mode 100644 index 0000000..94b63ef --- /dev/null +++ b/app/backend/src/departments/schemas/department.schema.ts @@ -0,0 +1,13 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type DepartmentDocument = Department & Document; + +@Schema({ timestamps: true }) +export class Department { + @Prop({ required: true, unique: true }) name: string; + @Prop() description: string; + @Prop({ default: true }) isActive: boolean; +} + +export const DepartmentSchema = SchemaFactory.createForClass(Department); diff --git a/app/backend/src/employees/dto/employee.dto.ts b/app/backend/src/employees/dto/employee.dto.ts new file mode 100644 index 0000000..a8e8c45 --- /dev/null +++ b/app/backend/src/employees/dto/employee.dto.ts @@ -0,0 +1,53 @@ +import { IsString, IsEmail, IsOptional, IsEnum, IsBoolean, IsNumber, IsDateString } from 'class-validator'; + +export class CreateEmployeeDto { + @IsString() employeeId: string; + @IsString() firstName: string; + @IsString() lastName: string; + @IsEmail() email: string; + @IsOptional() @IsString() phone?: string; + @IsOptional() @IsString() department?: string; + @IsOptional() @IsString() designation?: string; + @IsOptional() @IsDateString() dateOfBirth?: string; + @IsOptional() @IsDateString() dateOfJoining?: string; + @IsOptional() @IsString() address?: string; + @IsOptional() @IsString() panNumber?: string; + @IsOptional() @IsString() bankAccountNumber?: string; + @IsOptional() @IsString() bankIfscCode?: string; + @IsOptional() @IsEnum(['employee', 'hr_admin', 'super_admin']) role?: string; + @IsString() password: string; + @IsOptional() salaryStructure?: { + basic?: number; + hra?: number; + da?: number; + specialAllowance?: number; + pfEmployee?: number; + pfEmployer?: number; + professionalTax?: number; + }; +} + +export class UpdateEmployeeDto { + @IsOptional() @IsString() firstName?: string; + @IsOptional() @IsString() lastName?: string; + @IsOptional() @IsEmail() email?: string; + @IsOptional() @IsString() phone?: string; + @IsOptional() @IsString() department?: string; + @IsOptional() @IsString() designation?: string; + @IsOptional() @IsDateString() dateOfBirth?: string; + @IsOptional() @IsDateString() dateOfJoining?: string; + @IsOptional() @IsString() address?: string; + @IsOptional() @IsString() panNumber?: string; + @IsOptional() @IsString() bankAccountNumber?: string; + @IsOptional() @IsString() bankIfscCode?: string; + @IsOptional() @IsBoolean() isActive?: boolean; + @IsOptional() salaryStructure?: { + basic?: number; + hra?: number; + da?: number; + specialAllowance?: number; + pfEmployee?: number; + pfEmployer?: number; + professionalTax?: number; + }; +} diff --git a/app/backend/src/employees/employees.controller.ts b/app/backend/src/employees/employees.controller.ts new file mode 100644 index 0000000..9c67065 --- /dev/null +++ b/app/backend/src/employees/employees.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, Get, Post, Put, Delete, Body, Param, Query, + UseGuards, UploadedFile, UseInterceptors, Req +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { EmployeesService } from './employees.service'; +import { CreateEmployeeDto, UpdateEmployeeDto } from './dto/employee.dto'; +import { memoryStorage } from 'multer'; + +@Controller('employees') +@UseGuards(JwtAuthGuard, RolesGuard) +export class EmployeesController { + constructor(private employeesService: EmployeesService) {} + + @Get() + @Roles('hr_admin') + async findAll(@Query() query: any) { + return this.employeesService.findAll(query); + } + + @Post() + @Roles('hr_admin') + async create(@Body() dto: CreateEmployeeDto) { + return this.employeesService.create(dto); + } + + @Get(':id') + async findOne(@Param('id') id: string, @CurrentUser() user: any) { + return this.employeesService.findOne(id, user); + } + + @Put(':id') + @Roles('hr_admin') + async update(@Param('id') id: string, @Body() dto: UpdateEmployeeDto) { + return this.employeesService.update(id, dto); + } + + @Delete(':id') + @Roles('super_admin') + async remove(@Param('id') id: string) { + return this.employeesService.remove(id); + } + + @Post('import-csv') + @Roles('hr_admin') + @UseInterceptors(FileInterceptor('file', { storage: memoryStorage() })) + async importCsv(@UploadedFile() file: Express.Multer.File) { + return this.employeesService.importCsv(file.buffer); + } +} diff --git a/app/backend/src/employees/employees.module.ts b/app/backend/src/employees/employees.module.ts new file mode 100644 index 0000000..23542ef --- /dev/null +++ b/app/backend/src/employees/employees.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { EmployeesController } from './employees.controller'; +import { EmployeesService } from './employees.service'; +import { Employee, EmployeeSchema } from './schemas/employee.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Employee.name, schema: EmployeeSchema }]), + ], + controllers: [EmployeesController], + providers: [EmployeesService], + exports: [EmployeesService, MongooseModule], +}) +export class EmployeesModule {} diff --git a/app/backend/src/employees/employees.service.ts b/app/backend/src/employees/employees.service.ts new file mode 100644 index 0000000..91118c5 --- /dev/null +++ b/app/backend/src/employees/employees.service.ts @@ -0,0 +1,180 @@ +import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import * as bcrypt from 'bcryptjs'; +import { parse } from 'csv-parse/sync'; +import { Employee, EmployeeDocument } from './schemas/employee.schema'; +import { CreateEmployeeDto, UpdateEmployeeDto } from './dto/employee.dto'; + +@Injectable() +export class EmployeesService { + constructor( + @InjectModel(Employee.name) private employeeModel: Model, + ) {} + + async findAll(query: any) { + const { page = 1, limit = 20, department, search, isActive } = query; + const filter: any = {}; + + if (isActive !== undefined) { + filter.isActive = isActive === 'true' || isActive === true; + } + + if (department) { + filter.department = department; + } + + if (search) { + filter.$or = [ + { firstName: { $regex: search, $options: 'i' } }, + { lastName: { $regex: search, $options: 'i' } }, + { employeeId: { $regex: search, $options: 'i' } }, + { email: { $regex: search, $options: 'i' } }, + { designation: { $regex: search, $options: 'i' } }, + ]; + } + + const skip = (Number(page) - 1) * Number(limit); + const [employees, total] = await Promise.all([ + this.employeeModel + .find(filter) + .populate('department', 'name') + .select('-passwordHash -refreshToken') + .skip(skip) + .limit(Number(limit)) + .sort({ createdAt: -1 }) + .lean(), + this.employeeModel.countDocuments(filter), + ]); + + return { + data: employees, + total, + page: Number(page), + limit: Number(limit), + totalPages: Math.ceil(total / Number(limit)), + }; + } + + async findOne(id: string, currentUser: any) { + const query = Types.ObjectId.isValid(id) + ? { _id: id } + : { employeeId: id }; + + const employee = await this.employeeModel + .findOne(query) + .populate('department', 'name') + .select('-passwordHash -refreshToken') + .lean(); + + if (!employee) { + throw new NotFoundException('Employee not found'); + } + + if ( + currentUser.role === 'employee' && + employee._id.toString() !== currentUser._id.toString() + ) { + throw new ForbiddenException('Access denied'); + } + + return employee; + } + + async create(dto: CreateEmployeeDto) { + const existing = await this.employeeModel.findOne({ + $or: [{ employeeId: dto.employeeId }, { email: dto.email }], + }); + + if (existing) { + throw new ConflictException('Employee ID or email already exists'); + } + + const passwordHash = await bcrypt.hash(dto.password, 12); + + const employee = new this.employeeModel({ + ...dto, + passwordHash, + mustChangePassword: true, + department: dto.department ? new Types.ObjectId(dto.department) : undefined, + }); + + const saved = await employee.save(); + const result = saved.toObject(); + delete result.passwordHash; + delete result.refreshToken; + return result; + } + + async update(id: string, dto: UpdateEmployeeDto) { + const update: any = { ...dto }; + if (dto.department) { + update.department = new Types.ObjectId(dto.department); + } + + const employee = await this.employeeModel + .findByIdAndUpdate(id, update, { new: true }) + .populate('department', 'name') + .select('-passwordHash -refreshToken') + .lean(); + + if (!employee) { + throw new NotFoundException('Employee not found'); + } + + return employee; + } + + async remove(id: string) { + const employee = await this.employeeModel.findByIdAndUpdate( + id, + { isActive: false }, + { new: true }, + ); + + if (!employee) { + throw new NotFoundException('Employee not found'); + } + + return { message: 'Employee deactivated successfully' }; + } + + async importCsv(fileBuffer: Buffer) { + const records = parse(fileBuffer, { + columns: true, + skip_empty_lines: true, + }); + + const results = { success: 0, failed: 0, errors: [] }; + + for (const record of records) { + try { + await this.create({ + employeeId: record.employeeId, + firstName: record.firstName, + lastName: record.lastName, + email: record.email, + phone: record.phone, + department: record.department, + designation: record.designation, + password: record.password || 'TempPass@123', + role: record.role || 'employee', + }); + results.success++; + } catch (error) { + results.failed++; + results.errors.push({ record: record.employeeId, error: error.message }); + } + } + + return results; + } + + async updateProfilePhoto(id: string, photoUrl: string) { + return this.employeeModel.findByIdAndUpdate( + id, + { profilePhoto: photoUrl }, + { new: true }, + ).select('-passwordHash -refreshToken'); + } +} diff --git a/app/backend/src/employees/schemas/employee.schema.ts b/app/backend/src/employees/schemas/employee.schema.ts new file mode 100644 index 0000000..400065d --- /dev/null +++ b/app/backend/src/employees/schemas/employee.schema.ts @@ -0,0 +1,54 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type EmployeeDocument = Employee & Document; + +@Schema({ _id: false }) +class SalaryStructure { + @Prop({ default: 0 }) basic: number; + @Prop({ default: 0 }) hra: number; + @Prop({ default: 0 }) da: number; + @Prop({ default: 0 }) specialAllowance: number; + @Prop({ default: 0 }) pfEmployee: number; + @Prop({ default: 0 }) pfEmployer: number; + @Prop({ default: 0 }) professionalTax: number; +} + +@Schema({ _id: false }) +class LeaveBalance { + @Prop({ default: 12 }) casual: number; + @Prop({ default: 12 }) sick: number; + @Prop({ default: 15 }) earned: number; + @Prop({ default: 0 }) lop: number; +} + +@Schema({ timestamps: true }) +export class Employee { + @Prop({ required: true, unique: true }) employeeId: string; + @Prop({ required: true }) firstName: string; + @Prop({ required: true }) lastName: string; + @Prop({ required: true, unique: true }) email: string; + @Prop() phone: string; + @Prop({ type: Types.ObjectId, ref: 'Department' }) department: Types.ObjectId; + @Prop() designation: string; + @Prop() dateOfBirth: Date; + @Prop() dateOfJoining: Date; + @Prop() address: string; + @Prop() panNumber: string; + @Prop() bankAccountNumber: string; + @Prop() bankIfscCode: string; + @Prop() profilePhoto: string; + @Prop({ default: true }) isActive: boolean; + @Prop({ enum: ['employee', 'hr_admin', 'super_admin'], default: 'employee' }) role: string; + @Prop({ required: true }) passwordHash: string; + @Prop({ default: false }) mustChangePassword: boolean; + @Prop() refreshToken: string; + @Prop({ type: SalaryStructure, default: {} }) salaryStructure: SalaryStructure; + @Prop({ type: LeaveBalance, default: {} }) leaveBalance: LeaveBalance; +} + +export const EmployeeSchema = SchemaFactory.createForClass(Employee); + +EmployeeSchema.index({ employeeId: 1 }); +EmployeeSchema.index({ email: 1 }); +EmployeeSchema.index({ department: 1 }); diff --git a/app/backend/src/leaves/dto/leave.dto.ts b/app/backend/src/leaves/dto/leave.dto.ts new file mode 100644 index 0000000..18b7797 --- /dev/null +++ b/app/backend/src/leaves/dto/leave.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsEnum, IsDateString, IsNumber, IsOptional, Min } from 'class-validator'; + +export class ApplyLeaveDto { + @IsEnum(['casual', 'sick', 'earned', 'lop']) leaveType: string; + @IsDateString() fromDate: string; + @IsDateString() toDate: string; + @IsString() reason: string; +} + +export class ReviewLeaveDto { + @IsOptional() @IsString() comment?: string; +} diff --git a/app/backend/src/leaves/leaves.controller.ts b/app/backend/src/leaves/leaves.controller.ts new file mode 100644 index 0000000..240dae5 --- /dev/null +++ b/app/backend/src/leaves/leaves.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { LeavesService } from './leaves.service'; +import { ApplyLeaveDto, ReviewLeaveDto } from './dto/leave.dto'; + +@Controller('leaves') +@UseGuards(JwtAuthGuard, RolesGuard) +export class LeavesController { + constructor(private leavesService: LeavesService) {} + + @Post() + applyLeave(@CurrentUser() user: any, @Body() dto: ApplyLeaveDto) { + return this.leavesService.applyLeave(user._id.toString(), dto); + } + + @Get() + findAll(@Query() query: any, @CurrentUser() user: any) { + return this.leavesService.findAll(query, user); + } + + @Put(':id/approve') + @Roles('hr_admin') + approve(@Param('id') id: string, @CurrentUser() user: any, @Body() dto: ReviewLeaveDto) { + return this.leavesService.approve(id, user._id.toString(), dto); + } + + @Put(':id/reject') + @Roles('hr_admin') + reject(@Param('id') id: string, @CurrentUser() user: any, @Body() dto: ReviewLeaveDto) { + return this.leavesService.reject(id, user._id.toString(), dto); + } + + @Get('balance/:employeeId') + getBalance(@Param('employeeId') employeeId: string, @CurrentUser() user: any) { + const id = user.role === 'employee' ? user._id.toString() : employeeId; + return this.leavesService.getBalance(id); + } +} diff --git a/app/backend/src/leaves/leaves.module.ts b/app/backend/src/leaves/leaves.module.ts new file mode 100644 index 0000000..d7cbd28 --- /dev/null +++ b/app/backend/src/leaves/leaves.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { LeavesController } from './leaves.controller'; +import { LeavesService } from './leaves.service'; +import { Leave, LeaveSchema } from './schemas/leave.schema'; +import { Employee, EmployeeSchema } from '../employees/schemas/employee.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: Leave.name, schema: LeaveSchema }, + { name: Employee.name, schema: EmployeeSchema }, + ]), + ], + controllers: [LeavesController], + providers: [LeavesService], + exports: [LeavesService, MongooseModule], +}) +export class LeavesModule {} diff --git a/app/backend/src/leaves/leaves.service.ts b/app/backend/src/leaves/leaves.service.ts new file mode 100644 index 0000000..214a3c3 --- /dev/null +++ b/app/backend/src/leaves/leaves.service.ts @@ -0,0 +1,167 @@ +import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { Leave, LeaveDocument } from './schemas/leave.schema'; +import { Employee, EmployeeDocument } from '../employees/schemas/employee.schema'; +import { ApplyLeaveDto, ReviewLeaveDto } from './dto/leave.dto'; + +@Injectable() +export class LeavesService { + constructor( + @InjectModel(Leave.name) private leaveModel: Model, + @InjectModel(Employee.name) private employeeModel: Model, + ) {} + + private calculateDays(from: Date, to: Date): number { + const diff = to.getTime() - from.getTime(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1; + } + + async applyLeave(employeeId: string, dto: ApplyLeaveDto) { + const fromDate = new Date(dto.fromDate); + const toDate = new Date(dto.toDate); + + if (fromDate > toDate) { + throw new BadRequestException('From date must be before or equal to to date'); + } + + const numberOfDays = this.calculateDays(fromDate, toDate); + + // Check for overlapping leaves + const overlapping = await this.leaveModel.findOne({ + employeeId: new Types.ObjectId(employeeId), + status: { $in: ['pending', 'approved'] }, + $or: [ + { fromDate: { $lte: toDate }, toDate: { $gte: fromDate } }, + ], + }); + + if (overlapping) { + throw new BadRequestException('Leave dates overlap with an existing leave request'); + } + + // Check balance for non-LOP leaves + const employee = await this.employeeModel.findById(employeeId); + if (!employee) throw new NotFoundException('Employee not found'); + + if (dto.leaveType !== 'lop') { + const balanceKey = dto.leaveType === 'casual' ? 'casual' : + dto.leaveType === 'sick' ? 'sick' : 'earned'; + const balance = employee.leaveBalance[balanceKey] || 0; + + if (balance < numberOfDays) { + throw new BadRequestException(`Insufficient ${dto.leaveType} leave balance. Available: ${balance} days`); + } + } + + const leave = new this.leaveModel({ + employeeId: new Types.ObjectId(employeeId), + leaveType: dto.leaveType, + fromDate, + toDate, + numberOfDays, + reason: dto.reason, + status: 'pending', + }); + + return leave.save(); + } + + async findAll(query: any, currentUser: any) { + const filter: any = {}; + + if (currentUser.role === 'employee') { + filter.employeeId = new Types.ObjectId(currentUser._id); + } else if (query.employeeId) { + filter.employeeId = new Types.ObjectId(query.employeeId); + } + + if (query.status) { + filter.status = query.status; + } + + const page = parseInt(query.page) || 1; + const limit = parseInt(query.limit) || 20; + const skip = (page - 1) * limit; + + const [leaves, total] = await Promise.all([ + this.leaveModel + .find(filter) + .populate('employeeId', 'firstName lastName employeeId department') + .populate('reviewedBy', 'firstName lastName') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .lean(), + this.leaveModel.countDocuments(filter), + ]); + + return { data: leaves, total, page, limit }; + } + + async approve(id: string, reviewerId: string, dto: ReviewLeaveDto) { + const leave = await this.leaveModel.findById(id).populate('employeeId'); + if (!leave) throw new NotFoundException('Leave request not found'); + if (leave.status !== 'pending') throw new BadRequestException('Leave is not pending'); + + // Deduct leave balance + const employee = await this.employeeModel.findById(leave.employeeId); + if (employee && leave.leaveType !== 'lop') { + const balanceKey = leave.leaveType === 'casual' ? 'casual' : + leave.leaveType === 'sick' ? 'sick' : 'earned'; + employee.leaveBalance[balanceKey] = Math.max( + 0, + (employee.leaveBalance[balanceKey] || 0) - leave.numberOfDays, + ); + await employee.save(); + } + + return this.leaveModel.findByIdAndUpdate( + id, + { + status: 'approved', + reviewedBy: new Types.ObjectId(reviewerId), + reviewedAt: new Date(), + reviewComment: dto.comment, + }, + { new: true }, + ).populate('employeeId', 'firstName lastName employeeId'); + } + + async reject(id: string, reviewerId: string, dto: ReviewLeaveDto) { + const leave = await this.leaveModel.findById(id); + if (!leave) throw new NotFoundException('Leave request not found'); + if (leave.status !== 'pending') throw new BadRequestException('Leave is not pending'); + + if (!dto.comment) { + throw new BadRequestException('Rejection reason is required'); + } + + return this.leaveModel.findByIdAndUpdate( + id, + { + status: 'rejected', + reviewedBy: new Types.ObjectId(reviewerId), + reviewedAt: new Date(), + reviewComment: dto.comment, + }, + { new: true }, + ).populate('employeeId', 'firstName lastName employeeId'); + } + + async getBalance(employeeId: string) { + const employee = await this.employeeModel + .findById(employeeId) + .select('leaveBalance firstName lastName employeeId') + .lean(); + + if (!employee) throw new NotFoundException('Employee not found'); + + return { + employeeId: employee.employeeId, + firstName: employee.firstName, + lastName: employee.lastName, + balance: employee.leaveBalance, + }; + } +} diff --git a/app/backend/src/leaves/schemas/leave.schema.ts b/app/backend/src/leaves/schemas/leave.schema.ts new file mode 100644 index 0000000..db2f00e --- /dev/null +++ b/app/backend/src/leaves/schemas/leave.schema.ts @@ -0,0 +1,22 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type LeaveDocument = Leave & Document; + +@Schema({ timestamps: true }) +export class Leave { + @Prop({ type: Types.ObjectId, ref: 'Employee', required: true }) employeeId: Types.ObjectId; + @Prop({ enum: ['casual', 'sick', 'earned', 'lop'], required: true }) leaveType: string; + @Prop({ required: true }) fromDate: Date; + @Prop({ required: true }) toDate: Date; + @Prop({ required: true }) numberOfDays: number; + @Prop({ required: true }) reason: string; + @Prop({ enum: ['pending', 'approved', 'rejected'], default: 'pending' }) status: string; + @Prop({ type: Types.ObjectId, ref: 'Employee' }) reviewedBy: Types.ObjectId; + @Prop() reviewedAt: Date; + @Prop() reviewComment: string; +} + +export const LeaveSchema = SchemaFactory.createForClass(Leave); +LeaveSchema.index({ employeeId: 1, status: 1 }); +LeaveSchema.index({ fromDate: 1, toDate: 1 }); diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts new file mode 100644 index 0000000..a69bd2e --- /dev/null +++ b/app/backend/src/main.ts @@ -0,0 +1,31 @@ +import './tracing'; +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.enableCors({ + origin: true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: false, + }), + ); + + app.setGlobalPrefix('api/v1'); + + const port = process.env.PORT || 3001; + await app.listen(port); + console.log(`HR Portal Backend running on port ${port}`); +} + +bootstrap(); diff --git a/app/backend/src/payroll/dto/payroll.dto.ts b/app/backend/src/payroll/dto/payroll.dto.ts new file mode 100644 index 0000000..3bfacc2 --- /dev/null +++ b/app/backend/src/payroll/dto/payroll.dto.ts @@ -0,0 +1,12 @@ +import { IsNumber, IsInt, Min, Max } from 'class-validator'; + +export class GeneratePayrollDto { + @IsInt() + @Min(1) + @Max(12) + month: number; + + @IsInt() + @Min(2020) + year: number; +} diff --git a/app/backend/src/payroll/payroll.controller.ts b/app/backend/src/payroll/payroll.controller.ts new file mode 100644 index 0000000..490e2ed --- /dev/null +++ b/app/backend/src/payroll/payroll.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Post, Body, Param, Query, UseGuards, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { PayrollService } from './payroll.service'; +import { GeneratePayrollDto } from './dto/payroll.dto'; + +@Controller() +@UseGuards(JwtAuthGuard, RolesGuard) +export class PayrollController { + constructor(private payrollService: PayrollService) {} + + @Post('payroll/generate') + @Roles('hr_admin') + generate(@Body() dto: GeneratePayrollDto, @CurrentUser() user: any) { + return this.payrollService.generatePayroll(dto, user._id.toString()); + } + + @Get('payroll') + @Roles('hr_admin') + findPayrollRuns(@Query() query: any) { + return this.payrollService.findPayrollRuns(query); + } + + @Get('payslips') + findPayslips(@Query() query: any, @CurrentUser() user: any) { + return this.payrollService.findPayslips(query, user); + } + + @Get('payslips/:id') + findPayslipById(@Param('id') id: string) { + return this.payrollService.findPayslipById(id); + } + + @Get('payslips/:id/pdf') + async downloadPdf(@Param('id') id: string, @Res() res: Response) { + return this.payrollService.generatePdf(id, res); + } +} diff --git a/app/backend/src/payroll/payroll.module.ts b/app/backend/src/payroll/payroll.module.ts new file mode 100644 index 0000000..9a59a3a --- /dev/null +++ b/app/backend/src/payroll/payroll.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { PayrollController } from './payroll.controller'; +import { PayrollService } from './payroll.service'; +import { PayrollRun, PayrollRunSchema, Payslip, PayslipSchema } from './schemas/payroll.schema'; +import { Employee, EmployeeSchema } from '../employees/schemas/employee.schema'; +import { Leave, LeaveSchema } from '../leaves/schemas/leave.schema'; +import { TaxDeclaration, TaxDeclarationSchema } from '../tax/schemas/tax.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: PayrollRun.name, schema: PayrollRunSchema }, + { name: Payslip.name, schema: PayslipSchema }, + { name: Employee.name, schema: EmployeeSchema }, + { name: Leave.name, schema: LeaveSchema }, + { name: TaxDeclaration.name, schema: TaxDeclarationSchema }, + ]), + ], + controllers: [PayrollController], + providers: [PayrollService], + exports: [PayrollService, MongooseModule], +}) +export class PayrollModule {} diff --git a/app/backend/src/payroll/payroll.service.ts b/app/backend/src/payroll/payroll.service.ts new file mode 100644 index 0000000..a236fdf --- /dev/null +++ b/app/backend/src/payroll/payroll.service.ts @@ -0,0 +1,283 @@ +import { + Injectable, NotFoundException, ConflictException, BadRequestException +} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { Response } from 'express'; +import * as PDFDocument from 'pdfkit'; +import { PayrollRun, PayrollRunDocument, Payslip, PayslipDocument } from './schemas/payroll.schema'; +import { Employee, EmployeeDocument } from '../employees/schemas/employee.schema'; +import { Leave, LeaveDocument } from '../leaves/schemas/leave.schema'; +import { TaxDeclaration, TaxDeclarationDocument } from '../tax/schemas/tax.schema'; +import { GeneratePayrollDto } from './dto/payroll.dto'; + +@Injectable() +export class PayrollService { + constructor( + @InjectModel(PayrollRun.name) private payrollRunModel: Model, + @InjectModel(Payslip.name) private payslipModel: Model, + @InjectModel(Employee.name) private employeeModel: Model, + @InjectModel(Leave.name) private leaveModel: Model, + @InjectModel(TaxDeclaration.name) private taxDecModel: Model, + ) {} + + private calculatePT(annualSalary: number): number { + // Karnataka PT slabs + if (annualSalary <= 150000) return 0; + if (annualSalary <= 200000) return 1500 / 12; + return 2500 / 12; + } + + private calculateTDS(annualGross: number, declarations: any): number { + const deductions = Math.min(declarations?.section80C || 0, 150000); + const section80D = Math.min(declarations?.section80D || 0, 25000); + const hra = declarations?.hra || 0; + const lta = declarations?.lta || 0; + const homeLoanInterest = Math.min(declarations?.homeLoanInterest || 0, 200000); + + const totalDeductions = deductions + section80D + hra + lta + homeLoanInterest + 50000; // Standard deduction + const taxableIncome = Math.max(0, annualGross - totalDeductions); + + let tax = 0; + if (taxableIncome <= 250000) { + tax = 0; + } else if (taxableIncome <= 500000) { + tax = (taxableIncome - 250000) * 0.05; + } else if (taxableIncome <= 1000000) { + tax = 12500 + (taxableIncome - 500000) * 0.2; + } else { + tax = 112500 + (taxableIncome - 1000000) * 0.3; + } + + // Add 4% cess + tax = tax * 1.04; + + return Math.round(tax / 12); + } + + async generatePayroll(dto: GeneratePayrollDto, generatedBy: string) { + const existing = await this.payrollRunModel.findOne({ + month: dto.month, + year: dto.year, + }); + + if (existing) { + throw new ConflictException(`Payroll for ${dto.month}/${dto.year} already generated`); + } + + const employees = await this.employeeModel.find({ isActive: true }).lean(); + + const payrollRun = new this.payrollRunModel({ + month: dto.month, + year: dto.year, + status: 'processing', + generatedBy: new Types.ObjectId(generatedBy), + generatedAt: new Date(), + }); + + const savedRun = await payrollRun.save(); + let totalGross = 0; + let totalNet = 0; + + for (const employee of employees) { + const salary = (employee.salaryStructure || {}) as any; + const basic = salary.basic || 0; + const hra = salary.hra || 0; + const da = salary.da || 0; + const specialAllowance = salary.specialAllowance || 0; + const gross = basic + hra + da + specialAllowance; + + // Calculate LOP days + const fromDate = new Date(dto.year, dto.month - 1, 1); + const toDate = new Date(dto.year, dto.month, 0); + const lopLeaves = await this.leaveModel.find({ + employeeId: employee._id, + leaveType: 'lop', + status: 'approved', + fromDate: { $gte: fromDate }, + toDate: { $lte: toDate }, + }).lean(); + + const lopDays = lopLeaves.reduce((sum, l) => sum + l.numberOfDays, 0); + const workingDays = toDate.getDate(); + const lopDeduction = lopDays > 0 ? (gross / workingDays) * lopDays : 0; + const adjustedGross = gross - lopDeduction; + + // PF + const pfEmployee = Math.round(Math.min(basic, 15000) * 0.12); + const pfEmployer = Math.round(Math.min(basic, 15000) * 0.12); + + // Professional Tax + const pt = Math.round(this.calculatePT(adjustedGross * 12)); + + // Tax Declarations + const currentFY = dto.month >= 4 ? `${dto.year}-${dto.year + 1}` : `${dto.year - 1}-${dto.year}`; + const taxDec = await this.taxDecModel.findOne({ + employeeId: employee._id, + financialYear: currentFY, + }).lean(); + + const tds = this.calculateTDS(adjustedGross * 12, taxDec?.declarations); + const totalDeductions = pfEmployee + pt + tds; + const netPay = Math.round(adjustedGross - totalDeductions); + + const payslip = new this.payslipModel({ + employeeId: employee._id, + payrollRunId: savedRun._id, + month: dto.month, + year: dto.year, + earnings: { + basic: Math.round(basic), + hra: Math.round(hra), + da: Math.round(da), + specialAllowance: Math.round(specialAllowance), + gross: Math.round(adjustedGross), + }, + deductions: { + pfEmployee, + pfEmployer, + tds, + professionalTax: pt, + totalDeductions, + }, + netPay, + lopDays, + status: 'generated', + }); + + await payslip.save(); + totalGross += Math.round(adjustedGross); + totalNet += netPay; + } + + return this.payrollRunModel.findByIdAndUpdate( + savedRun._id, + { + status: 'completed', + totalEmployees: employees.length, + totalGross, + totalNet, + }, + { new: true }, + ); + } + + async findPayrollRuns(query: any) { + const filter: any = {}; + if (query.month) filter.month = parseInt(query.month); + if (query.year) filter.year = parseInt(query.year); + + return this.payrollRunModel + .find(filter) + .populate('generatedBy', 'firstName lastName') + .sort({ year: -1, month: -1 }) + .lean(); + } + + async findPayslips(query: any, currentUser: any) { + const filter: any = {}; + + if (currentUser.role === 'employee') { + filter.employeeId = new Types.ObjectId(currentUser._id); + } else if (query.employeeId) { + filter.employeeId = new Types.ObjectId(query.employeeId); + } + + if (query.month) filter.month = parseInt(query.month); + if (query.year) filter.year = parseInt(query.year); + + return this.payslipModel + .find(filter) + .populate('employeeId', 'firstName lastName employeeId department designation') + .populate('payrollRunId') + .sort({ year: -1, month: -1 }) + .lean(); + } + + async findPayslipById(id: string) { + const payslip = await this.payslipModel + .findById(id) + .populate('employeeId', 'firstName lastName employeeId department designation email panNumber bankAccountNumber bankIfscCode') + .populate('payrollRunId') + .lean(); + + if (!payslip) throw new NotFoundException('Payslip not found'); + return payslip; + } + + async generatePdf(id: string, res: Response) { + const payslip = await this.payslipModel + .findById(id) + .populate<{ employeeId: any }>('employeeId', 'firstName lastName employeeId department designation email panNumber') + .lean(); + + if (!payslip) throw new NotFoundException('Payslip not found'); + + const doc = new PDFDocument({ margin: 50 }); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename=payslip-${payslip.month}-${payslip.year}.pdf`, + ); + + doc.pipe(res); + + // Header + doc.fontSize(20).font('Helvetica-Bold').text('HR PORTAL', { align: 'center' }); + doc.fontSize(14).font('Helvetica').text('PAYSLIP', { align: 'center' }); + doc.moveDown(); + + const monthNames = ['', 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + doc.fontSize(12).text(`Pay Period: ${monthNames[payslip.month]} ${payslip.year}`, { align: 'center' }); + doc.moveDown(); + + // Employee Info + doc.fontSize(10).font('Helvetica-Bold').text('Employee Details'); + doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke(); + doc.moveDown(0.5); + + const emp = payslip.employeeId as any; + doc.font('Helvetica'); + doc.text(`Name: ${emp?.firstName} ${emp?.lastName}`); + doc.text(`Employee ID: ${emp?.employeeId}`); + doc.text(`Designation: ${emp?.designation || 'N/A'}`); + doc.text(`PAN: ${emp?.panNumber || 'N/A'}`); + doc.moveDown(); + + // Earnings + doc.font('Helvetica-Bold').text('Earnings'); + doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke(); + doc.moveDown(0.5); + doc.font('Helvetica'); + + const earnings = payslip.earnings as any; + doc.text(`Basic Salary: โ‚น${earnings?.basic?.toLocaleString() || 0}`); + doc.text(`HRA: โ‚น${earnings?.hra?.toLocaleString() || 0}`); + doc.text(`DA: โ‚น${earnings?.da?.toLocaleString() || 0}`); + doc.text(`Special Allowance: โ‚น${earnings?.specialAllowance?.toLocaleString() || 0}`); + doc.font('Helvetica-Bold').text(`Gross Pay: โ‚น${earnings?.gross?.toLocaleString() || 0}`); + doc.moveDown(); + + // Deductions + doc.font('Helvetica-Bold').text('Deductions'); + doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke(); + doc.moveDown(0.5); + doc.font('Helvetica'); + + const deductions = payslip.deductions as any; + doc.text(`PF (Employee): โ‚น${deductions?.pfEmployee?.toLocaleString() || 0}`); + doc.text(`Professional Tax: โ‚น${deductions?.professionalTax?.toLocaleString() || 0}`); + doc.text(`TDS: โ‚น${deductions?.tds?.toLocaleString() || 0}`); + doc.font('Helvetica-Bold').text(`Total Deductions: โ‚น${deductions?.totalDeductions?.toLocaleString() || 0}`); + doc.moveDown(); + + // Net Pay + doc.fontSize(14).font('Helvetica-Bold'); + doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke(); + doc.moveDown(0.5); + doc.text(`NET PAY: โ‚น${payslip.netPay?.toLocaleString() || 0}`, { align: 'right' }); + + doc.end(); + } +} diff --git a/app/backend/src/payroll/schemas/payroll.schema.ts b/app/backend/src/payroll/schemas/payroll.schema.ts new file mode 100644 index 0000000..5e5ab49 --- /dev/null +++ b/app/backend/src/payroll/schemas/payroll.schema.ts @@ -0,0 +1,54 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type PayrollRunDocument = PayrollRun & Document; +export type PayslipDocument = Payslip & Document; + +@Schema({ _id: false }) +class Earnings { + @Prop({ default: 0 }) basic: number; + @Prop({ default: 0 }) hra: number; + @Prop({ default: 0 }) da: number; + @Prop({ default: 0 }) specialAllowance: number; + @Prop({ default: 0 }) gross: number; +} + +@Schema({ _id: false }) +class Deductions { + @Prop({ default: 0 }) pfEmployee: number; + @Prop({ default: 0 }) pfEmployer: number; + @Prop({ default: 0 }) tds: number; + @Prop({ default: 0 }) professionalTax: number; + @Prop({ default: 0 }) totalDeductions: number; +} + +@Schema({ timestamps: true }) +export class PayrollRun { + @Prop({ required: true }) month: number; + @Prop({ required: true }) year: number; + @Prop({ enum: ['pending', 'processing', 'completed'], default: 'completed' }) status: string; + @Prop({ type: Types.ObjectId, ref: 'Employee' }) generatedBy: Types.ObjectId; + @Prop({ default: 0 }) totalEmployees: number; + @Prop({ default: 0 }) totalGross: number; + @Prop({ default: 0 }) totalNet: number; + @Prop() generatedAt: Date; +} + +export const PayrollRunSchema = SchemaFactory.createForClass(PayrollRun); +PayrollRunSchema.index({ month: 1, year: 1 }, { unique: true }); + +@Schema({ timestamps: true }) +export class Payslip { + @Prop({ type: Types.ObjectId, ref: 'Employee', required: true }) employeeId: Types.ObjectId; + @Prop({ type: Types.ObjectId, ref: 'PayrollRun', required: true }) payrollRunId: Types.ObjectId; + @Prop({ required: true }) month: number; + @Prop({ required: true }) year: number; + @Prop({ type: Earnings, default: {} }) earnings: Earnings; + @Prop({ type: Deductions, default: {} }) deductions: Deductions; + @Prop({ default: 0 }) netPay: number; + @Prop({ enum: ['generated', 'sent'], default: 'generated' }) status: string; + @Prop() lopDays: number; +} + +export const PayslipSchema = SchemaFactory.createForClass(Payslip); +PayslipSchema.index({ employeeId: 1, month: 1, year: 1 }); diff --git a/app/backend/src/reimbursements/dto/reimbursement.dto.ts b/app/backend/src/reimbursements/dto/reimbursement.dto.ts new file mode 100644 index 0000000..df44ed5 --- /dev/null +++ b/app/backend/src/reimbursements/dto/reimbursement.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsEnum, IsNumber, IsOptional, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateReimbursementDto { + @IsEnum(['travel', 'food', 'medical', 'other']) category: string; + @Type(() => Number) + @IsNumber() + @Min(1) + amount: number; + @IsString() description: string; +} + +export class ReviewReimbursementDto { + @IsOptional() @IsString() comment?: string; +} diff --git a/app/backend/src/reimbursements/reimbursements.controller.ts b/app/backend/src/reimbursements/reimbursements.controller.ts new file mode 100644 index 0000000..cb2c3c8 --- /dev/null +++ b/app/backend/src/reimbursements/reimbursements.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, Get, Post, Put, Body, Param, Query, + UseGuards, UploadedFile, UseInterceptors +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage, memoryStorage } from 'multer'; +import { extname } from 'path'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { ReimbursementsService } from './reimbursements.service'; +import { CreateReimbursementDto, ReviewReimbursementDto } from './dto/reimbursement.dto'; + +@Controller('reimbursements') +@UseGuards(JwtAuthGuard, RolesGuard) +export class ReimbursementsController { + constructor(private reimbursementsService: ReimbursementsService) {} + + @Post() + @UseInterceptors( + FileInterceptor('receipt', { + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const ext = extname(file.originalname).toLowerCase(); + if (['.pdf', '.jpg', '.jpeg', '.png'].includes(ext)) { + cb(null, true); + } else { + cb(new Error('Only PDF, JPG, PNG files are allowed'), false); + } + }, + }), + ) + async create( + @CurrentUser() user: any, + @Body() dto: CreateReimbursementDto, + @UploadedFile() file?: Express.Multer.File, + ) { + const receiptUrl = file + ? `data:${file.mimetype};base64,${file.buffer.toString('base64')}` + : undefined; + return this.reimbursementsService.create(user._id.toString(), dto, receiptUrl); + } + + @Get() + findAll(@Query() query: any, @CurrentUser() user: any) { + return this.reimbursementsService.findAll(query, user); + } + + @Put(':id/approve') + @Roles('hr_admin') + approve(@Param('id') id: string, @CurrentUser() user: any, @Body() dto: ReviewReimbursementDto) { + return this.reimbursementsService.approve(id, user._id.toString(), dto); + } + + @Put(':id/reject') + @Roles('hr_admin') + reject(@Param('id') id: string, @CurrentUser() user: any, @Body() dto: ReviewReimbursementDto) { + return this.reimbursementsService.reject(id, user._id.toString(), dto); + } + + @Put(':id/mark-paid') + @Roles('hr_admin') + markPaid(@Param('id') id: string, @CurrentUser() user: any) { + return this.reimbursementsService.markPaid(id, user._id.toString()); + } +} diff --git a/app/backend/src/reimbursements/reimbursements.module.ts b/app/backend/src/reimbursements/reimbursements.module.ts new file mode 100644 index 0000000..7d8ad4e --- /dev/null +++ b/app/backend/src/reimbursements/reimbursements.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { ReimbursementsController } from './reimbursements.controller'; +import { ReimbursementsService } from './reimbursements.service'; +import { Reimbursement, ReimbursementSchema } from './schemas/reimbursement.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Reimbursement.name, schema: ReimbursementSchema }]), + ], + controllers: [ReimbursementsController], + providers: [ReimbursementsService], + exports: [ReimbursementsService, MongooseModule], +}) +export class ReimbursementsModule {} diff --git a/app/backend/src/reimbursements/reimbursements.service.ts b/app/backend/src/reimbursements/reimbursements.service.ts new file mode 100644 index 0000000..b92bf66 --- /dev/null +++ b/app/backend/src/reimbursements/reimbursements.service.ts @@ -0,0 +1,106 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { Reimbursement, ReimbursementDocument } from './schemas/reimbursement.schema'; +import { CreateReimbursementDto, ReviewReimbursementDto } from './dto/reimbursement.dto'; + +@Injectable() +export class ReimbursementsService { + constructor( + @InjectModel(Reimbursement.name) + private reimbursementModel: Model, + ) {} + + async create(employeeId: string, dto: CreateReimbursementDto, receiptUrl?: string) { + const reimbursement = new this.reimbursementModel({ + employeeId: new Types.ObjectId(employeeId), + category: dto.category, + amount: dto.amount, + description: dto.description, + receiptUrl, + status: 'pending', + }); + return reimbursement.save(); + } + + async findAll(query: any, currentUser: any) { + const filter: any = {}; + + if (currentUser.role === 'employee') { + filter.employeeId = new Types.ObjectId(currentUser._id); + } else if (query.employeeId) { + filter.employeeId = new Types.ObjectId(query.employeeId); + } + + if (query.status) filter.status = query.status; + + const page = parseInt(query.page) || 1; + const limit = parseInt(query.limit) || 20; + + const [data, total] = await Promise.all([ + this.reimbursementModel + .find(filter) + .populate('employeeId', 'firstName lastName employeeId') + .populate('reviewedBy', 'firstName lastName') + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(limit) + .lean(), + this.reimbursementModel.countDocuments(filter), + ]); + + return { data, total, page, limit }; + } + + async approve(id: string, reviewerId: string, dto: ReviewReimbursementDto) { + const r = await this.reimbursementModel.findById(id); + if (!r) throw new NotFoundException('Reimbursement not found'); + if (r.status !== 'pending') throw new BadRequestException('Not in pending state'); + + return this.reimbursementModel.findByIdAndUpdate( + id, + { + status: 'approved', + reviewedBy: new Types.ObjectId(reviewerId), + reviewedAt: new Date(), + reviewComment: dto.comment, + }, + { new: true }, + ).populate('employeeId', 'firstName lastName employeeId'); + } + + async reject(id: string, reviewerId: string, dto: ReviewReimbursementDto) { + const r = await this.reimbursementModel.findById(id); + if (!r) throw new NotFoundException('Reimbursement not found'); + if (r.status !== 'pending') throw new BadRequestException('Not in pending state'); + + if (!dto.comment) throw new BadRequestException('Rejection reason required'); + + return this.reimbursementModel.findByIdAndUpdate( + id, + { + status: 'rejected', + reviewedBy: new Types.ObjectId(reviewerId), + reviewedAt: new Date(), + reviewComment: dto.comment, + }, + { new: true }, + ).populate('employeeId', 'firstName lastName employeeId'); + } + + async markPaid(id: string, reviewerId: string) { + const r = await this.reimbursementModel.findById(id); + if (!r) throw new NotFoundException('Reimbursement not found'); + if (r.status !== 'approved') throw new BadRequestException('Must be approved first'); + + return this.reimbursementModel.findByIdAndUpdate( + id, + { + status: 'paid', + reviewedBy: new Types.ObjectId(reviewerId), + reviewedAt: new Date(), + }, + { new: true }, + ).populate('employeeId', 'firstName lastName employeeId'); + } +} diff --git a/app/backend/src/reimbursements/schemas/reimbursement.schema.ts b/app/backend/src/reimbursements/schemas/reimbursement.schema.ts new file mode 100644 index 0000000..3d4e13b --- /dev/null +++ b/app/backend/src/reimbursements/schemas/reimbursement.schema.ts @@ -0,0 +1,20 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type ReimbursementDocument = Reimbursement & Document; + +@Schema({ timestamps: true }) +export class Reimbursement { + @Prop({ type: Types.ObjectId, ref: 'Employee', required: true }) employeeId: Types.ObjectId; + @Prop({ enum: ['travel', 'food', 'medical', 'other'], required: true }) category: string; + @Prop({ required: true }) amount: number; + @Prop({ required: true }) description: string; + @Prop() receiptUrl: string; + @Prop({ enum: ['pending', 'approved', 'rejected', 'paid'], default: 'pending' }) status: string; + @Prop({ type: Types.ObjectId, ref: 'Employee' }) reviewedBy: Types.ObjectId; + @Prop() reviewedAt: Date; + @Prop() reviewComment: string; +} + +export const ReimbursementSchema = SchemaFactory.createForClass(Reimbursement); +ReimbursementSchema.index({ employeeId: 1, status: 1 }); diff --git a/app/backend/src/reports/reports.controller.ts b/app/backend/src/reports/reports.controller.ts new file mode 100644 index 0000000..7159380 --- /dev/null +++ b/app/backend/src/reports/reports.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { ReportsService } from './reports.service'; + +@Controller('reports') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('hr_admin') +export class ReportsController { + constructor(private reportsService: ReportsService) {} + + @Get('headcount') + getHeadcount() { + return this.reportsService.getHeadcount(); + } + + @Get('payroll-summary') + getPayrollSummary(@Query('month') month: string, @Query('year') year: string) { + return this.reportsService.getPayrollSummary(parseInt(month), parseInt(year)); + } + + @Get('leave-utilization') + getLeaveUtilization(@Query('year') year: string) { + return this.reportsService.getLeaveUtilization(parseInt(year)); + } + + @Get('attendance-summary') + getAttendanceSummary(@Query('month') month: string, @Query('year') year: string) { + return this.reportsService.getAttendanceSummary(parseInt(month), parseInt(year)); + } +} diff --git a/app/backend/src/reports/reports.module.ts b/app/backend/src/reports/reports.module.ts new file mode 100644 index 0000000..84bf5d2 --- /dev/null +++ b/app/backend/src/reports/reports.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; +import { Employee, EmployeeSchema } from '../employees/schemas/employee.schema'; +import { Attendance, AttendanceSchema } from '../attendance/schemas/attendance.schema'; +import { Leave, LeaveSchema } from '../leaves/schemas/leave.schema'; +import { Payslip, PayslipSchema } from '../payroll/schemas/payroll.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: Employee.name, schema: EmployeeSchema }, + { name: Attendance.name, schema: AttendanceSchema }, + { name: Leave.name, schema: LeaveSchema }, + { name: Payslip.name, schema: PayslipSchema }, + ]), + ], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/app/backend/src/reports/reports.service.ts b/app/backend/src/reports/reports.service.ts new file mode 100644 index 0000000..f0c5f24 --- /dev/null +++ b/app/backend/src/reports/reports.service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { Employee, EmployeeDocument } from '../employees/schemas/employee.schema'; +import { Attendance, AttendanceDocument } from '../attendance/schemas/attendance.schema'; +import { Leave, LeaveDocument } from '../leaves/schemas/leave.schema'; +import { Payslip, PayslipDocument } from '../payroll/schemas/payroll.schema'; + +@Injectable() +export class ReportsService { + constructor( + @InjectModel(Employee.name) private employeeModel: Model, + @InjectModel(Attendance.name) private attendanceModel: Model, + @InjectModel(Leave.name) private leaveModel: Model, + @InjectModel(Payslip.name) private payslipModel: Model, + ) {} + + async getHeadcount() { + const byDept = await this.employeeModel.aggregate([ + { $match: { isActive: true } }, + { + $lookup: { + from: 'departments', + localField: 'department', + foreignField: '_id', + as: 'deptInfo', + }, + }, + { $unwind: { path: '$deptInfo', preserveNullAndEmptyArrays: true } }, + { + $group: { + _id: '$deptInfo.name', + count: { $sum: 1 }, + }, + }, + { $sort: { count: -1 } }, + ]); + + const byDesignation = await this.employeeModel.aggregate([ + { $match: { isActive: true } }, + { $group: { _id: '$designation', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + ]); + + const total = await this.employeeModel.countDocuments({ isActive: true }); + + return { total, byDepartment: byDept, byDesignation }; + } + + async getPayrollSummary(month: number, year: number) { + const payslips = await this.payslipModel + .find({ month, year }) + .populate('employeeId', 'firstName lastName department designation') + .lean(); + + const summary = payslips.reduce( + (acc, p) => { + const e = p.earnings as any; + const d = p.deductions as any; + acc.totalGross += e?.gross || 0; + acc.totalPF += (d?.pfEmployee || 0) + (d?.pfEmployer || 0); + acc.totalTDS += d?.tds || 0; + acc.totalPT += d?.professionalTax || 0; + acc.totalNet += p.netPay || 0; + return acc; + }, + { totalGross: 0, totalPF: 0, totalTDS: 0, totalPT: 0, totalNet: 0 }, + ); + + return { + month, + year, + employeeCount: payslips.length, + ...summary, + payslips, + }; + } + + async getLeaveUtilization(year: number) { + const startDate = new Date(year, 0, 1); + const endDate = new Date(year, 11, 31); + + const leaves = await this.leaveModel + .find({ + status: 'approved', + fromDate: { $gte: startDate }, + toDate: { $lte: endDate }, + }) + .populate('employeeId', 'firstName lastName department') + .lean(); + + const byType = leaves.reduce((acc, l) => { + acc[l.leaveType] = (acc[l.leaveType] || 0) + l.numberOfDays; + return acc; + }, {}); + + const byEmployee = leaves.reduce((acc, l) => { + const emp = l.employeeId as any; + const key = emp?._id?.toString(); + if (!acc[key]) { + acc[key] = { + employee: emp, + casual: 0, sick: 0, earned: 0, lop: 0, total: 0, + }; + } + acc[key][l.leaveType] = (acc[key][l.leaveType] || 0) + l.numberOfDays; + acc[key].total += l.numberOfDays; + return acc; + }, {}); + + return { + year, + totalDays: leaves.reduce((s, l) => s + l.numberOfDays, 0), + byType, + byEmployee: Object.values(byEmployee), + }; + } + + async getAttendanceSummary(month: number, year: number) { + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0, 23, 59, 59); + + const attendance = await this.attendanceModel + .find({ date: { $gte: startDate, $lte: endDate } }) + .populate('employeeId', 'firstName lastName department') + .lean(); + + const byEmployee = attendance.reduce((acc, a) => { + const emp = a.employeeId as any; + const key = emp?._id?.toString(); + if (!acc[key]) { + acc[key] = { + employee: emp, + present: 0, absent: 0, wfh: 0, half_day: 0, holiday: 0, + }; + } + acc[key][a.status] = (acc[key][a.status] || 0) + 1; + return acc; + }, {}); + + return { + month, + year, + byEmployee: Object.values(byEmployee), + }; + } +} diff --git a/app/backend/src/seed.service.ts b/app/backend/src/seed.service.ts new file mode 100644 index 0000000..b99d846 --- /dev/null +++ b/app/backend/src/seed.service.ts @@ -0,0 +1,274 @@ +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import * as bcrypt from 'bcryptjs'; +import { Employee, EmployeeDocument } from './employees/schemas/employee.schema'; +import { Department, DepartmentDocument } from './departments/schemas/department.schema'; +import { Announcement, AnnouncementDocument } from './announcements/schemas/announcement.schema'; +import { Attendance, AttendanceDocument } from './attendance/schemas/attendance.schema'; +import { Leave, LeaveDocument } from './leaves/schemas/leave.schema'; + +@Injectable() +export class SeedService implements OnApplicationBootstrap { + constructor( + @InjectModel(Employee.name) private employeeModel: Model, + @InjectModel(Department.name) private departmentModel: Model, + @InjectModel(Announcement.name) private announcementModel: Model, + @InjectModel(Attendance.name) private attendanceModel: Model, + @InjectModel(Leave.name) private leaveModel: Model, + ) {} + + async onApplicationBootstrap() { + const count = await this.employeeModel.countDocuments(); + if (count > 0) { + console.log('Database already seeded, skipping...'); + return; + } + + console.log('Seeding database...'); + + // Departments + const departments = await this.departmentModel.insertMany([ + { name: 'Engineering', description: 'Software Engineering' }, + { name: 'Human Resources', description: 'HR and People Operations' }, + { name: 'Finance', description: 'Finance and Accounting' }, + { name: 'Marketing', description: 'Marketing and Growth' }, + { name: 'Operations', description: 'Business Operations' }, + ]); + + const engDept = departments[0]; + const hrDept = departments[1]; + const finDept = departments[2]; + + const hash = async (pw: string) => bcrypt.hash(pw, 12); + + // Super Admin + const superAdmin = await this.employeeModel.create({ + employeeId: 'ADMIN001', + firstName: 'Super', + lastName: 'Admin', + email: 'admin@hrportal.com', + phone: '9999999999', + department: hrDept._id, + designation: 'Super Administrator', + dateOfJoining: new Date('2020-01-01'), + isActive: true, + role: 'super_admin', + passwordHash: await hash('Admin@123'), + mustChangePassword: false, + leaveBalance: { casual: 12, sick: 12, earned: 15, lop: 0 }, + salaryStructure: { basic: 100000, hra: 40000, da: 10000, specialAllowance: 20000, pfEmployee: 12000, pfEmployer: 12000, professionalTax: 200 }, + }); + + // HR Admin + const hrAdmin = await this.employeeModel.create({ + employeeId: 'HR001', + firstName: 'Priya', + lastName: 'Sharma', + email: 'priya.sharma@hrportal.com', + phone: '9988776655', + department: hrDept._id, + designation: 'HR Manager', + dateOfJoining: new Date('2020-03-15'), + isActive: true, + role: 'hr_admin', + passwordHash: await hash('Hr@12345'), + mustChangePassword: false, + leaveBalance: { casual: 10, sick: 11, earned: 12, lop: 0 }, + salaryStructure: { basic: 80000, hra: 32000, da: 8000, specialAllowance: 15000, pfEmployee: 9600, pfEmployer: 9600, professionalTax: 200 }, + }); + + // Employees + const employees = await this.employeeModel.insertMany([ + { + employeeId: 'EMP001', + firstName: 'Rahul', + lastName: 'Verma', + email: 'rahul.verma@hrportal.com', + phone: '9876543210', + department: engDept._id, + designation: 'Senior Software Engineer', + dateOfBirth: new Date('1992-05-15'), + dateOfJoining: new Date('2021-06-01'), + panNumber: 'ABCPV1234D', + bankAccountNumber: '12345678901', + bankIfscCode: 'HDFC0001234', + isActive: true, + role: 'employee', + passwordHash: await hash('Emp@12345'), + mustChangePassword: false, + leaveBalance: { casual: 8, sick: 10, earned: 12, lop: 0 }, + salaryStructure: { basic: 60000, hra: 24000, da: 6000, specialAllowance: 10000, pfEmployee: 7200, pfEmployer: 7200, professionalTax: 200 }, + }, + { + employeeId: 'EMP002', + firstName: 'Anita', + lastName: 'Patel', + email: 'anita.patel@hrportal.com', + phone: '9876543211', + department: finDept._id, + designation: 'Finance Analyst', + dateOfBirth: new Date('1994-08-20'), + dateOfJoining: new Date('2022-01-10'), + panNumber: 'ABCPA5678E', + bankAccountNumber: '12345678902', + bankIfscCode: 'ICIC0001234', + isActive: true, + role: 'employee', + passwordHash: await hash('Emp@12345'), + mustChangePassword: false, + leaveBalance: { casual: 12, sick: 12, earned: 15, lop: 0 }, + salaryStructure: { basic: 50000, hra: 20000, da: 5000, specialAllowance: 8000, pfEmployee: 6000, pfEmployer: 6000, professionalTax: 200 }, + }, + { + employeeId: 'EMP003', + firstName: 'Vikram', + lastName: 'Singh', + email: 'vikram.singh@hrportal.com', + phone: '9876543212', + department: engDept._id, + designation: 'Junior Developer', + dateOfBirth: new Date('1997-03-10'), + dateOfJoining: new Date('2023-04-01'), + isActive: true, + role: 'employee', + passwordHash: await hash('Emp@12345'), + mustChangePassword: false, + leaveBalance: { casual: 12, sick: 12, earned: 15, lop: 0 }, + salaryStructure: { basic: 35000, hra: 14000, da: 3500, specialAllowance: 5000, pfEmployee: 4200, pfEmployer: 4200, professionalTax: 150 }, + }, + { + employeeId: 'EMP004', + firstName: 'Deepa', + lastName: 'Nair', + email: 'deepa.nair@hrportal.com', + phone: '9876543213', + department: departments[3]._id, + designation: 'Marketing Manager', + dateOfBirth: new Date('1990-11-25'), + dateOfJoining: new Date('2020-08-15'), + isActive: true, + role: 'employee', + passwordHash: await hash('Emp@12345'), + mustChangePassword: false, + leaveBalance: { casual: 9, sick: 11, earned: 13, lop: 0 }, + salaryStructure: { basic: 70000, hra: 28000, da: 7000, specialAllowance: 12000, pfEmployee: 8400, pfEmployer: 8400, professionalTax: 200 }, + }, + { + employeeId: 'EMP005', + firstName: 'Arjun', + lastName: 'Kumar', + email: 'arjun.kumar@hrportal.com', + phone: '9876543214', + department: departments[4]._id, + designation: 'Operations Lead', + dateOfBirth: new Date('1988-07-30'), + dateOfJoining: new Date('2019-11-01'), + isActive: true, + role: 'employee', + passwordHash: await hash('Emp@12345'), + mustChangePassword: false, + leaveBalance: { casual: 7, sick: 9, earned: 10, lop: 0 }, + salaryStructure: { basic: 75000, hra: 30000, da: 7500, specialAllowance: 13000, pfEmployee: 9000, pfEmployer: 9000, professionalTax: 200 }, + }, + ]); + + // Attendance for the current month + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth(); + const allEmployees = [superAdmin, hrAdmin, ...employees]; + + for (const emp of allEmployees) { + const daysToMark = Math.min(today.getDate() - 1, 20); + for (let day = 1; day <= daysToMark; day++) { + const date = new Date(year, month, day); + if (date.getDay() === 0 || date.getDay() === 6) continue; + const statusOptions = ['present', 'present', 'present', 'wfh', 'present']; + const status = statusOptions[Math.floor(Math.random() * statusOptions.length)]; + try { + await this.attendanceModel.create({ + employeeId: emp._id, + date, + status, + markedBy: 'admin', + markedAt: new Date(), + }); + } catch (e) { + // Skip duplicates + } + } + } + + // Leave requests + await this.leaveModel.insertMany([ + { + employeeId: employees[0]._id, + leaveType: 'casual', + fromDate: new Date(year, month + 1, 5), + toDate: new Date(year, month + 1, 7), + numberOfDays: 3, + reason: 'Family function', + status: 'pending', + }, + { + employeeId: employees[1]._id, + leaveType: 'sick', + fromDate: new Date(year, month - 1, 10), + toDate: new Date(year, month - 1, 11), + numberOfDays: 2, + reason: 'Fever and cold', + status: 'approved', + reviewedBy: hrAdmin._id, + reviewedAt: new Date(year, month - 1, 10), + }, + { + employeeId: employees[2]._id, + leaveType: 'earned', + fromDate: new Date(year, month + 1, 15), + toDate: new Date(year, month + 1, 20), + numberOfDays: 6, + reason: 'Annual vacation', + status: 'pending', + }, + ]); + + // Announcements + await this.announcementModel.insertMany([ + { + title: 'Welcome to HR Portal!', + content: 'We are excited to launch our new HR Portal. Please explore all features and reach out to HR for any questions.', + targetAudience: 'all', + createdBy: superAdmin._id, + isActive: true, + }, + { + title: 'Q1 Performance Reviews', + content: 'Q1 performance reviews will be conducted from next month. Please complete your self-assessments by the 15th.', + targetAudience: 'all', + createdBy: hrAdmin._id, + isActive: true, + }, + { + title: 'New Leave Policy Update', + content: 'The leave policy has been updated for the current financial year. Please review the updated policy in the HR section.', + targetAudience: 'all', + createdBy: hrAdmin._id, + isActive: true, + }, + { + title: 'Office Holiday - Republic Day', + content: 'The office will remain closed on 26th January for Republic Day. Wishing everyone a Happy Republic Day!', + targetAudience: 'all', + createdBy: hrAdmin._id, + isActive: true, + }, + ]); + + console.log('Database seeded successfully!'); + console.log('Login credentials:'); + console.log(' Super Admin: ADMIN001 / Admin@123'); + console.log(' HR Admin: HR001 / Hr@12345'); + console.log(' Employee: EMP001 / Emp@12345'); + } +} diff --git a/app/backend/src/tax/dto/tax.dto.ts b/app/backend/src/tax/dto/tax.dto.ts new file mode 100644 index 0000000..80ea739 --- /dev/null +++ b/app/backend/src/tax/dto/tax.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsOptional, IsNumber, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class TaxDeclarationDto { + @IsOptional() @IsString() financialYear?: string; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) section80C?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) section80D?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) hra?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) lta?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) homeLoanInterest?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) otherDeductions?: number; +} diff --git a/app/backend/src/tax/schemas/tax.schema.ts b/app/backend/src/tax/schemas/tax.schema.ts new file mode 100644 index 0000000..264dd53 --- /dev/null +++ b/app/backend/src/tax/schemas/tax.schema.ts @@ -0,0 +1,39 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type TaxDeclarationDocument = TaxDeclaration & Document; +export type AuditLogDocument = AuditLog & Document; + +@Schema({ _id: false }) +class Declarations { + @Prop({ default: 0 }) section80C: number; + @Prop({ default: 0 }) section80D: number; + @Prop({ default: 0 }) hra: number; + @Prop({ default: 0 }) lta: number; + @Prop({ default: 0 }) homeLoanInterest: number; + @Prop({ default: 0 }) otherDeductions: number; +} + +@Schema({ timestamps: true }) +export class TaxDeclaration { + @Prop({ type: Types.ObjectId, ref: 'Employee', required: true }) employeeId: Types.ObjectId; + @Prop({ required: true }) financialYear: string; + @Prop({ type: Declarations, default: {} }) declarations: Declarations; + @Prop() submittedAt: Date; + @Prop({ enum: ['draft', 'submitted', 'approved'], default: 'draft' }) status: string; +} + +export const TaxDeclarationSchema = SchemaFactory.createForClass(TaxDeclaration); +TaxDeclarationSchema.index({ employeeId: 1, financialYear: 1 }, { unique: true }); + +@Schema({ timestamps: true }) +export class AuditLog { + @Prop({ type: Types.ObjectId, ref: 'Employee' }) performedBy: Types.ObjectId; + @Prop({ required: true }) action: string; + @Prop({ required: true }) entity: string; + @Prop() entityId: string; + @Prop({ type: Object }) details: any; + @Prop() ipAddress: string; +} + +export const AuditLogSchema = SchemaFactory.createForClass(AuditLog); diff --git a/app/backend/src/tax/tax.controller.ts b/app/backend/src/tax/tax.controller.ts new file mode 100644 index 0000000..f73f872 --- /dev/null +++ b/app/backend/src/tax/tax.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { TaxService } from './tax.service'; +import { TaxDeclarationDto } from './dto/tax.dto'; + +@Controller('tax') +@UseGuards(JwtAuthGuard, RolesGuard) +export class TaxController { + constructor(private taxService: TaxService) {} + + @Post('declarations') + submitDeclaration(@CurrentUser() user: any, @Body() dto: TaxDeclarationDto) { + return this.taxService.submitDeclaration(user._id.toString(), dto); + } + + @Get('declarations/:employeeId') + getDeclaration(@Param('employeeId') employeeId: string, @CurrentUser() user: any) { + const id = user.role === 'employee' ? user._id.toString() : employeeId; + return this.taxService.getDeclaration(id); + } + + @Get('tds-projection/:employeeId') + getTDSProjection(@Param('employeeId') employeeId: string, @CurrentUser() user: any) { + const id = user.role === 'employee' ? user._id.toString() : employeeId; + return this.taxService.getTDSProjection(id); + } +} diff --git a/app/backend/src/tax/tax.module.ts b/app/backend/src/tax/tax.module.ts new file mode 100644 index 0000000..289b786 --- /dev/null +++ b/app/backend/src/tax/tax.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { TaxController } from './tax.controller'; +import { TaxService } from './tax.service'; +import { TaxDeclaration, TaxDeclarationSchema, AuditLog, AuditLogSchema } from './schemas/tax.schema'; +import { Employee, EmployeeSchema } from '../employees/schemas/employee.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: TaxDeclaration.name, schema: TaxDeclarationSchema }, + { name: AuditLog.name, schema: AuditLogSchema }, + { name: Employee.name, schema: EmployeeSchema }, + ]), + ], + controllers: [TaxController], + providers: [TaxService], + exports: [TaxService, MongooseModule], +}) +export class TaxModule {} diff --git a/app/backend/src/tax/tax.service.ts b/app/backend/src/tax/tax.service.ts new file mode 100644 index 0000000..6803d9f --- /dev/null +++ b/app/backend/src/tax/tax.service.ts @@ -0,0 +1,122 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { TaxDeclaration, TaxDeclarationDocument } from './schemas/tax.schema'; +import { Employee, EmployeeDocument } from '../employees/schemas/employee.schema'; +import { TaxDeclarationDto } from './dto/tax.dto'; + +@Injectable() +export class TaxService { + constructor( + @InjectModel(TaxDeclaration.name) + private taxDecModel: Model, + @InjectModel(Employee.name) + private employeeModel: Model, + ) {} + + private getCurrentFY(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + return month >= 4 ? `${year}-${year + 1}` : `${year - 1}-${year}`; + } + + async submitDeclaration(employeeId: string, dto: TaxDeclarationDto) { + const fy = dto.financialYear || this.getCurrentFY(); + + const declarations = { + section80C: dto.section80C || 0, + section80D: dto.section80D || 0, + hra: dto.hra || 0, + lta: dto.lta || 0, + homeLoanInterest: dto.homeLoanInterest || 0, + otherDeductions: dto.otherDeductions || 0, + }; + + return this.taxDecModel.findOneAndUpdate( + { employeeId: new Types.ObjectId(employeeId), financialYear: fy }, + { + employeeId: new Types.ObjectId(employeeId), + financialYear: fy, + declarations, + submittedAt: new Date(), + status: 'submitted', + }, + { upsert: true, new: true }, + ); + } + + async getDeclaration(employeeId: string) { + const fy = this.getCurrentFY(); + const dec = await this.taxDecModel.findOne({ + employeeId: new Types.ObjectId(employeeId), + financialYear: fy, + }).lean(); + + return dec || { + employeeId, + financialYear: fy, + declarations: {}, + status: 'draft', + }; + } + + async getTDSProjection(employeeId: string) { + const employee = await this.employeeModel.findById(employeeId).lean(); + if (!employee) throw new NotFoundException('Employee not found'); + + const salary = (employee.salaryStructure || {}) as any; + const monthly = (salary.basic || 0) + (salary.hra || 0) + (salary.da || 0) + (salary.specialAllowance || 0); + const annualGross = monthly * 12; + + const fy = this.getCurrentFY(); + const dec = await this.taxDecModel.findOne({ + employeeId: new Types.ObjectId(employeeId), + financialYear: fy, + }).lean(); + + const declarations = (dec?.declarations || {}) as any; + + const section80C = Math.min(declarations.section80C || 0, 150000); + const section80D = Math.min(declarations.section80D || 0, 25000); + const hra = declarations.hra || 0; + const lta = declarations.lta || 0; + const homeLoanInterest = Math.min(declarations.homeLoanInterest || 0, 200000); + const standardDeduction = 50000; + + const totalDeductions = section80C + section80D + hra + lta + homeLoanInterest + standardDeduction; + const taxableIncome = Math.max(0, annualGross - totalDeductions); + + let annualTax = 0; + if (taxableIncome <= 250000) { + annualTax = 0; + } else if (taxableIncome <= 500000) { + annualTax = (taxableIncome - 250000) * 0.05; + } else if (taxableIncome <= 1000000) { + annualTax = 12500 + (taxableIncome - 500000) * 0.2; + } else { + annualTax = 112500 + (taxableIncome - 1000000) * 0.3; + } + annualTax = annualTax * 1.04; // cess + + const monthlyTDS = Math.round(annualTax / 12); + + return { + financialYear: fy, + annualGross, + totalDeductions, + taxableIncome, + annualTax: Math.round(annualTax), + monthlyTDS, + breakdown: { + grossSalary: annualGross, + standardDeduction, + section80C, + section80D, + hra, + lta, + homeLoanInterest, + }, + }; + } +} diff --git a/app/backend/src/tracing.ts b/app/backend/src/tracing.ts new file mode 100644 index 0000000..6e64e43 --- /dev/null +++ b/app/backend/src/tracing.ts @@ -0,0 +1,22 @@ +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { Resource } from '@opentelemetry/resources'; +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; + +const sdk = new NodeSDK({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'hr-portal-backend', + }), + traceExporter: new OTLPTraceExporter({ + url: process.env.SIGNOZ_OTEL_ENDPOINT || 'http://100.64.0.10:4318/v1/traces', + }), + instrumentations: [getNodeAutoInstrumentations()], +}); + +try { + sdk.start(); + console.log('OpenTelemetry SDK started'); +} catch (error) { + console.error('Failed to start OpenTelemetry SDK:', error); +} diff --git a/app/backend/tsconfig.json b/app/backend/tsconfig.json new file mode 100644 index 0000000..73bca72 --- /dev/null +++ b/app/backend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2020", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/app/docker-compose.yml b/app/docker-compose.yml new file mode 100644 index 0000000..2608a90 --- /dev/null +++ b/app/docker-compose.yml @@ -0,0 +1,60 @@ +version: '3.8' + +services: + mongodb: + image: mongo:7 + restart: always + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + environment: + MONGO_INITDB_DATABASE: hr_portal + + backend: + build: + context: . + dockerfile: Dockerfile + target: backend-builder + ports: + - "3001:3001" + environment: + MONGODB_URI: mongodb://mongodb:27017/hr_portal + JWT_SECRET: dev-secret-key-change-in-production + JWT_REFRESH_SECRET: dev-refresh-secret-change-in-production + PORT: 3001 + NODE_ENV: development + SIGNOZ_OTEL_ENDPOINT: http://100.64.0.10:4318/v1/traces + depends_on: + - mongodb + command: node dist/main.js + + frontend: + build: + context: . + dockerfile: Dockerfile + target: frontend-builder + ports: + - "3000:3000" + environment: + NEXT_PUBLIC_API_URL: http://localhost:3001 + command: node .next/standalone/server.js + + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "80:80" + environment: + MONGODB_URI: mongodb://mongodb:27017/hr_portal + JWT_SECRET: dev-secret-key-change-in-production + JWT_REFRESH_SECRET: dev-refresh-secret-change-in-production + PORT: 3001 + NODE_ENV: production + SIGNOZ_OTEL_ENDPOINT: http://100.64.0.10:4318/v1/traces + depends_on: + - mongodb + +volumes: + mongo_data: diff --git a/app/frontend/app/(dashboard)/announcements/page.tsx b/app/frontend/app/(dashboard)/announcements/page.tsx new file mode 100644 index 0000000..a215828 --- /dev/null +++ b/app/frontend/app/(dashboard)/announcements/page.tsx @@ -0,0 +1,54 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { announcementsApi } from '@/lib/api'; +import { formatDate } from '@/lib/utils'; +import Topbar from '@/components/layout/Topbar'; + +export default function AnnouncementsPage() { + const [announcements, setAnnouncements] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + announcementsApi.getAll().then((res) => { + setAnnouncements(res.data); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + return ( +
+ +
+ {loading ? ( +
+ ) : announcements.length === 0 ? ( +
+

๐Ÿ“ข

+

No announcements at this time

+
+ ) : ( +
+ {announcements.map((ann: any) => ( +
+
+
+

{ann.title}

+

{ann.content}

+
+ + {ann.targetAudience === 'all' ? 'All' : ann.targetDepartment?.name} + +
+
+ By {ann.createdBy?.firstName} {ann.createdBy?.lastName} + โ€ข + {formatDate(ann.createdAt)} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/frontend/app/(dashboard)/attendance/page.tsx b/app/frontend/app/(dashboard)/attendance/page.tsx new file mode 100644 index 0000000..d0d29f0 --- /dev/null +++ b/app/frontend/app/(dashboard)/attendance/page.tsx @@ -0,0 +1,188 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useAuth } from '@/lib/auth-context'; +import { attendanceApi } from '@/lib/api'; +import { getDaysInMonth, monthNames } from '@/lib/utils'; +import Topbar from '@/components/layout/Topbar'; + +const statusColors: Record = { + present: 'bg-green-500', + absent: 'bg-red-400', + wfh: 'bg-blue-500', + half_day: 'bg-yellow-500', + holiday: 'bg-purple-500', +}; + +const statusLabels: Record = { + present: 'Present', + absent: 'Absent', + wfh: 'WFH', + half_day: 'Half Day', + holiday: 'Holiday', +}; + +export default function AttendancePage() { + const { user } = useAuth(); + const today = new Date(); + const [year, setYear] = useState(today.getFullYear()); + const [month, setMonth] = useState(today.getMonth() + 1); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [markedToday, setMarkedToday] = useState(false); + const [marking, setMarking] = useState(false); + + useEffect(() => { + if (!user) return; + setLoading(true); + attendanceApi.getAll({ month, year }).then((res) => { + setRecords(res.data); + const todayStr = today.toISOString().split('T')[0]; + setMarkedToday(res.data.some((r: any) => { + const rDate = new Date(r.date).toISOString().split('T')[0]; + return rDate === todayStr; + })); + setLoading(false); + }).catch(() => setLoading(false)); + }, [user, month, year]); + + const handleMark = async () => { + setMarking(true); + try { + await attendanceApi.mark({ status: 'present' }); + setMarkedToday(true); + const res = await attendanceApi.getAll({ month, year }); + setRecords(res.data); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to mark attendance'); + } + setMarking(false); + }; + + const getRecordForDay = (day: number) => { + return records.find((r) => { + const d = new Date(r.date); + return d.getDate() === day && d.getMonth() + 1 === month && d.getFullYear() === year; + }); + }; + + const daysInMonth = getDaysInMonth(year, month); + const firstDay = new Date(year, month - 1, 1).getDay(); + const summary = records.reduce((acc, r) => { + acc[r.status] = (acc[r.status] || 0) + 1; + return acc; + }, {} as Record); + + return ( +
+ +
+
+
+ {/* Month Selector */} +
+

{monthNames[month]} {year}

+
+ + +
+
+ + {/* Calendar */} +
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => ( +
{d}
+ ))} +
+
+ {Array.from({ length: firstDay }).map((_, i) => ( +
+ ))} + {Array.from({ length: daysInMonth }, (_, i) => i + 1).map((day) => { + const record = getRecordForDay(day); + const isToday = day === today.getDate() && month === today.getMonth() + 1 && year === today.getFullYear(); + const isWeekend = new Date(year, month - 1, day).getDay() === 0 || new Date(year, month - 1, day).getDay() === 6; + return ( +
+ {day} +
+ ); + })} +
+ + {/* Legend */} +
+ {Object.entries(statusColors).map(([status, color]) => ( +
+
+ {statusLabels[status]} +
+ ))} +
+
+ + {/* Summary Sidebar */} +
+ {/* Mark Today */} + {!markedToday && month === today.getMonth() + 1 && year === today.getFullYear() && ( +
+

Mark Today's Attendance

+

You haven't marked attendance for today.

+ +
+ )} + {markedToday && ( +
+

Attendance Marked!

+

You are marked as present today.

+
+ )} + + {/* Monthly Summary */} +
+

Monthly Summary

+
+ {Object.entries(statusLabels).map(([status, label]) => ( +
+
+
+ {label} +
+ {summary[status] || 0} +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/app/frontend/app/(dashboard)/dashboard/page.tsx b/app/frontend/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..3886955 --- /dev/null +++ b/app/frontend/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,168 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useAuth } from '@/lib/auth-context'; +import { leavesApi, attendanceApi, reimbursementsApi, announcementsApi } from '@/lib/api'; +import { formatDate } from '@/lib/utils'; +import Topbar from '@/components/layout/Topbar'; +import Link from 'next/link'; + +export default function EmployeeDashboard() { + const { user } = useAuth(); + const [leaveBalance, setLeaveBalance] = useState(null); + const [attendance, setAttendance] = useState([]); + const [reimbursements, setReimbursements] = useState([]); + const [announcements, setAnnouncements] = useState([]); + const [markedToday, setMarkedToday] = useState(false); + const [markingAttendance, setMarkingAttendance] = useState(false); + const [loading, setLoading] = useState(true); + + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth() + 1; + + useEffect(() => { + if (!user) return; + const fetchData = async () => { + try { + const [leaveRes, attRes, reimRes, annRes] = await Promise.allSettled([ + leavesApi.getBalance(user._id), + attendanceApi.getAll({ month, year }), + reimbursementsApi.getAll({ status: 'pending' }), + announcementsApi.getAll(), + ]); + + if (leaveRes.status === 'fulfilled') setLeaveBalance(leaveRes.value.data); + if (attRes.status === 'fulfilled') { + const records = attRes.value.data; + setAttendance(records); + const todayStr = today.toISOString().split('T')[0]; + setMarkedToday(records.some((r: any) => { + const rDate = new Date(r.date).toISOString().split('T')[0]; + return rDate === todayStr; + })); + } + if (reimRes.status === 'fulfilled') setReimbursements(reimRes.value.data.data || []); + if (annRes.status === 'fulfilled') setAnnouncements(annRes.value.data.slice(0, 5)); + } catch {} + setLoading(false); + }; + fetchData(); + }, [user]); + + const handleMarkAttendance = async () => { + setMarkingAttendance(true); + try { + await attendanceApi.mark({ status: 'present' }); + setMarkedToday(true); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to mark attendance'); + } + setMarkingAttendance(false); + }; + + const presentDays = attendance.filter((a) => a.status === 'present' || a.status === 'wfh').length; + const totalDays = attendance.length || 1; + const attendancePct = Math.round((presentDays / totalDays) * 100); + + return ( +
+ +
+
+

+ Good {today.getHours() < 12 ? 'morning' : today.getHours() < 17 ? 'afternoon' : 'evening'},{' '} + {user?.firstName}! +

+

{formatDate(today)}

+
+ + {/* Summary Cards */} +
+
+

Leave Balance

+
+

+ {leaveBalance?.balance?.casual || 0} +

+

casual days

+
+
+ Sick: {leaveBalance?.balance?.sick || 0} | Earned: {leaveBalance?.balance?.earned || 0} +
+
+ +
+

Attendance This Month

+

{attendancePct}%

+

{presentDays} of {totalDays} days

+
+ +
+

Pending Reimbursements

+

{reimbursements.length}

+

Awaiting approval

+
+ +
+

Today's Status

+

+ {markedToday ? 'Marked Present' : 'Not Marked'} +

+ {!markedToday && ( + + )} +
+
+ + {/* Quick Actions & Announcements */} +
+
+

Quick Actions

+
+ + ๐Ÿ– Apply for Leave + + + ๐Ÿ“„ Submit Reimbursement + + + ๐Ÿ’ฐ View Payslips + + + ๐Ÿงพ Tax Declaration + +
+
+ +
+

Announcements

+ {announcements.length === 0 ? ( +

No announcements

+ ) : ( +
+ {announcements.map((ann: any) => ( +
+
+ ๐Ÿ“ข +
+
+

{ann.title}

+

{ann.content}

+

{formatDate(ann.createdAt)}

+
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/app/frontend/app/(dashboard)/layout.tsx b/app/frontend/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..f3e6956 --- /dev/null +++ b/app/frontend/app/(dashboard)/layout.tsx @@ -0,0 +1,37 @@ +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/auth-context'; +import Sidebar from '@/components/layout/Sidebar'; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && !user) { + router.push('/login'); + } + }, [user, loading, router]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + return ( +
+ +
{children}
+
+ ); +} diff --git a/app/frontend/app/(dashboard)/leave/page.tsx b/app/frontend/app/(dashboard)/leave/page.tsx new file mode 100644 index 0000000..3130219 --- /dev/null +++ b/app/frontend/app/(dashboard)/leave/page.tsx @@ -0,0 +1,199 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useAuth } from '@/lib/auth-context'; +import { leavesApi } from '@/lib/api'; +import { formatDate, getStatusColor } from '@/lib/utils'; +import Topbar from '@/components/layout/Topbar'; + +export default function LeavePage() { + const { user } = useAuth(); + const [balance, setBalance] = useState(null); + const [leaves, setLeaves] = useState([]); + const [showForm, setShowForm] = useState(false); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState({ + leaveType: 'casual', + fromDate: '', + toDate: '', + reason: '', + }); + + const fetchData = async () => { + if (!user) return; + try { + const [balRes, leaveRes] = await Promise.allSettled([ + leavesApi.getBalance(user._id), + leavesApi.getAll(), + ]); + if (balRes.status === 'fulfilled') setBalance(balRes.value.data); + if (leaveRes.status === 'fulfilled') setLeaves(leaveRes.value.data.data || []); + } catch {} + setLoading(false); + }; + + useEffect(() => { fetchData(); }, [user]); + + const handleApply = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + try { + await leavesApi.apply(form); + setShowForm(false); + setForm({ leaveType: 'casual', fromDate: '', toDate: '', reason: '' }); + await fetchData(); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to apply leave'); + } + setSubmitting(false); + }; + + const balanceItems = [ + { label: 'Casual Leave', key: 'casual', color: 'indigo' }, + { label: 'Sick Leave', key: 'sick', color: 'blue' }, + { label: 'Earned Leave', key: 'earned', color: 'green' }, + ]; + + return ( +
+ +
+ {/* Balance Cards */} +
+ {balanceItems.map((item) => ( +
+

{item.label}

+

+ {balance?.balance?.[item.key] || 0} +

+

days available

+
+ ))} +
+ + {/* Apply Leave */} +
+
+

My Leave Requests

+ +
+ + {showForm && ( +
+

New Leave Application

+
+
+ + +
+
+ + setForm({ ...form, fromDate: e.target.value })} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ + setForm({ ...form, toDate: e.target.value })} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ + setForm({ ...form, reason: e.target.value })} + required + placeholder="Brief reason" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+
+ + +
+
+ )} + + {/* Leave History */} + {loading ? ( +
+ ) : leaves.length === 0 ? ( +
+

๐Ÿ–

+

No leave requests yet

+
+ ) : ( +
+ + + + + + + + + + + + + {leaves.map((leave: any) => ( + + + + + + + + + ))} + +
TypeFromToDaysReasonStatus
{leave.leaveType}{formatDate(leave.fromDate)}{formatDate(leave.toDate)}{leave.numberOfDays}{leave.reason} + + {leave.status} + +
+
+ )} +
+
+
+ ); +} diff --git a/app/frontend/app/(dashboard)/payslips/page.tsx b/app/frontend/app/(dashboard)/payslips/page.tsx new file mode 100644 index 0000000..8ed39f1 --- /dev/null +++ b/app/frontend/app/(dashboard)/payslips/page.tsx @@ -0,0 +1,150 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useAuth } from '@/lib/auth-context'; +import { payrollApi } from '@/lib/api'; +import { formatCurrency, monthNames } from '@/lib/utils'; +import Topbar from '@/components/layout/Topbar'; + +export default function PayslipsPage() { + const { user } = useAuth(); + const [payslips, setPayslips] = useState([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(true); + const [downloading, setDownloading] = useState(false); + + useEffect(() => { + if (!user) return; + payrollApi.getPayslips().then((res) => { + setPayslips(res.data); + setLoading(false); + }).catch(() => setLoading(false)); + }, [user]); + + const handleDownload = async (id: string) => { + setDownloading(true); + try { + const res = await payrollApi.downloadPdf(id); + const url = window.URL.createObjectURL(new Blob([res.data])); + const a = document.createElement('a'); + a.href = url; + a.download = `payslip-${id}.pdf`; + a.click(); + } catch { alert('Failed to download PDF'); } + setDownloading(false); + }; + + return ( +
+ +
+
+ {/* Payslip List */} +
+

Payslip History

+ {loading ? ( +
+ ) : payslips.length === 0 ? ( +

No payslips generated yet

+ ) : ( +
+ {payslips.map((ps: any) => ( + + ))} +
+ )} +
+ + {/* Payslip Detail */} +
+ {!selected ? ( +
+
+

๐Ÿ’ฐ

+

Select a payslip to view details

+
+
+ ) : ( +
+
+
+

{monthNames[selected.month]} {selected.year}

+

Pay Period

+
+ +
+ +
+ {/* Earnings */} +
+

Earnings

+
+ {[ + { label: 'Basic Salary', value: selected.earnings?.basic }, + { label: 'HRA', value: selected.earnings?.hra }, + { label: 'DA', value: selected.earnings?.da }, + { label: 'Special Allowance', value: selected.earnings?.specialAllowance }, + ].map((item) => ( +
+ {item.label} + {formatCurrency(item.value || 0)} +
+ ))} +
+ Gross Pay + {formatCurrency(selected.earnings?.gross || 0)} +
+
+
+ + {/* Deductions */} +
+

Deductions

+
+ {[ + { label: 'PF (Employee)', value: selected.deductions?.pfEmployee }, + { label: 'Professional Tax', value: selected.deductions?.professionalTax }, + { label: 'TDS', value: selected.deductions?.tds }, + ].map((item) => ( +
+ {item.label} + {formatCurrency(item.value || 0)} +
+ ))} +
+ Total Deductions + {formatCurrency(selected.deductions?.totalDeductions || 0)} +
+
+
+
+ + {/* Net Pay */} +
+ Net Pay + {formatCurrency(selected.netPay || 0)} +
+
+ )} +
+
+
+
+ ); +} diff --git a/app/frontend/app/(dashboard)/profile/page.tsx b/app/frontend/app/(dashboard)/profile/page.tsx new file mode 100644 index 0000000..4b004a5 --- /dev/null +++ b/app/frontend/app/(dashboard)/profile/page.tsx @@ -0,0 +1,110 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useAuth } from '@/lib/auth-context'; +import { employeesApi } from '@/lib/api'; +import { formatDate } from '@/lib/utils'; +import Topbar from '@/components/layout/Topbar'; + +export default function ProfilePage() { + const { user } = useAuth(); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!user) return; + employeesApi.getOne(user._id).then((res) => { + setProfile(res.data); + setLoading(false); + }).catch(() => setLoading(false)); + }, [user]); + + if (loading) return ( +
+ +
+
+
+
+ ); + + return ( +
+ +
+
+
+
+ {profile?.firstName?.[0]}{profile?.lastName?.[0]} +
+
+

{profile?.firstName} {profile?.lastName}

+

{profile?.designation}

+ + {profile?.role?.replace('_', ' ')} + +
+
+ +
+
+

Personal Info

+
+ {[ + { label: 'Employee ID', value: profile?.employeeId }, + { label: 'Email', value: profile?.email }, + { label: 'Phone', value: profile?.phone }, + { label: 'Date of Birth', value: profile?.dateOfBirth ? formatDate(profile.dateOfBirth) : 'N/A' }, + { label: 'Address', value: profile?.address }, + ].map((item) => ( +
+ {item.label} + {item.value || 'N/A'} +
+ ))} +
+
+ +
+

Work Info

+
+ {[ + { label: 'Department', value: profile?.department?.name }, + { label: 'Date of Joining', value: profile?.dateOfJoining ? formatDate(profile.dateOfJoining) : 'N/A' }, + { label: 'PAN Number', value: profile?.panNumber }, + { label: 'Bank Account', value: profile?.bankAccountNumber ? `****${profile.bankAccountNumber.slice(-4)}` : 'N/A' }, + { label: 'IFSC Code', value: profile?.bankIfscCode }, + ].map((item) => ( +
+ {item.label} + {item.value || 'N/A'} +
+ ))} +
+
+
+
+ + {profile?.salaryStructure && ( +
+

Salary Structure

+
+ {[ + { label: 'Basic', value: profile.salaryStructure.basic }, + { label: 'HRA', value: profile.salaryStructure.hra }, + { label: 'DA', value: profile.salaryStructure.da }, + { label: 'Special Allowance', value: profile.salaryStructure.specialAllowance }, + ].map((item) => ( +
+

{item.label}

+

+ โ‚น{(item.value || 0).toLocaleString()} +

+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/app/frontend/app/(dashboard)/reimbursements/page.tsx b/app/frontend/app/(dashboard)/reimbursements/page.tsx new file mode 100644 index 0000000..8d4ae76 --- /dev/null +++ b/app/frontend/app/(dashboard)/reimbursements/page.tsx @@ -0,0 +1,164 @@ +'use client'; +import { useEffect, useState, useRef } from 'react'; +import { useAuth } from '@/lib/auth-context'; +import { reimbursementsApi } from '@/lib/api'; +import { formatDate, formatCurrency, getStatusColor } from '@/lib/utils'; +import Topbar from '@/components/layout/Topbar'; + +export default function ReimbursementsPage() { + const { user } = useAuth(); + const [reimbursements, setReimbursements] = useState([]); + const [showForm, setShowForm] = useState(false); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState({ category: 'travel', amount: '', description: '' }); + const [receipt, setReceipt] = useState(null); + const fileRef = useRef(null); + + const fetchData = async () => { + if (!user) return; + try { + const res = await reimbursementsApi.getAll(); + setReimbursements(res.data.data || []); + } catch {} + setLoading(false); + }; + + useEffect(() => { fetchData(); }, [user]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + try { + await reimbursementsApi.create(form, receipt || undefined); + setShowForm(false); + setForm({ category: 'travel', amount: '', description: '' }); + setReceipt(null); + await fetchData(); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to submit'); + } + setSubmitting(false); + }; + + return ( +
+ +
+
+
+

My Reimbursements

+ +
+ + {showForm && ( +
+

Submit Reimbursement Claim

+
+
+ + +
+
+ + setForm({ ...form, amount: e.target.value })} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + placeholder="Enter amount" + /> +
+
+ +