deploy: hr-portal

This commit is contained in:
TenX PM
2026-05-04 19:33:55 +00:00
commit f68674020a
112 changed files with 17119 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.next/
dist/
*.local
.env
.env.local
uploads/
+44
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+7780
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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"
}
}
+47
View File
@@ -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);
}
}
+21
View File
@@ -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 {}
+98
View File
@@ -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<EmployeeDocument>,
@InjectModel(AuditLog.name) private auditLogModel: Model<AuditLogDocument>,
@InjectModel(OrgSettings.name) private orgSettingsModel: Model<OrgSettingsDocument>,
) {}
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();
}
}
@@ -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);
@@ -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);
}
}
@@ -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 {}
@@ -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<AnnouncementDocument>,
) {}
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' };
}
}
@@ -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;
}
@@ -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);
+47
View File
@@ -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 {}
@@ -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);
}
}
@@ -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 {}
@@ -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<AttendanceDocument>,
) {}
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;
}
}
@@ -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;
}
@@ -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 });
+38
View File
@@ -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;
}
}
+23
View File
@@ -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 {}
+116
View File
@@ -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<EmployeeDocument>,
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' };
}
}
+27
View File
@@ -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;
}
@@ -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<EmployeeDocument>,
) {
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,
};
}
}
@@ -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;
},
);
@@ -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);
@@ -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;
}
}
+39
View File
@@ -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<Role[]>(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<Role, number> = {
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;
}
}
@@ -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);
}
}
@@ -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 {}
@@ -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<DepartmentDocument>,
) {}
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' };
}
}
@@ -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;
}
@@ -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);
+53
View File
@@ -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;
};
}
@@ -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);
}
}
+15
View File
@@ -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 {}
+180
View File
@@ -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<EmployeeDocument>,
) {}
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');
}
}
@@ -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 });
+12
View File
@@ -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;
}
+41
View File
@@ -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);
}
}
+19
View File
@@ -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 {}
+167
View File
@@ -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<LeaveDocument>,
@InjectModel(Employee.name) private employeeModel: Model<EmployeeDocument>,
) {}
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,
};
}
}
@@ -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 });
+31
View File
@@ -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();
+12
View File
@@ -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;
}
+41
View File
@@ -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);
}
}
+24
View File
@@ -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 {}
+283
View File
@@ -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<PayrollRunDocument>,
@InjectModel(Payslip.name) private payslipModel: Model<PayslipDocument>,
@InjectModel(Employee.name) private employeeModel: Model<EmployeeDocument>,
@InjectModel(Leave.name) private leaveModel: Model<LeaveDocument>,
@InjectModel(TaxDeclaration.name) private taxDecModel: Model<TaxDeclarationDocument>,
) {}
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();
}
}
@@ -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 });
@@ -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;
}
@@ -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());
}
}
@@ -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 {}
@@ -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<ReimbursementDocument>,
) {}
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');
}
}
@@ -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 });
+32
View File
@@ -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));
}
}
+22
View File
@@ -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 {}
+147
View File
@@ -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<EmployeeDocument>,
@InjectModel(Attendance.name) private attendanceModel: Model<AttendanceDocument>,
@InjectModel(Leave.name) private leaveModel: Model<LeaveDocument>,
@InjectModel(Payslip.name) private payslipModel: Model<PayslipDocument>,
) {}
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),
};
}
}
+274
View File
@@ -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<EmployeeDocument>,
@InjectModel(Department.name) private departmentModel: Model<DepartmentDocument>,
@InjectModel(Announcement.name) private announcementModel: Model<AnnouncementDocument>,
@InjectModel(Attendance.name) private attendanceModel: Model<AttendanceDocument>,
@InjectModel(Leave.name) private leaveModel: Model<LeaveDocument>,
) {}
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');
}
}
+12
View File
@@ -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;
}
+39
View File
@@ -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);
+29
View File
@@ -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);
}
}
+20
View File
@@ -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 {}
+122
View File
@@ -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<TaxDeclarationDocument>,
@InjectModel(Employee.name)
private employeeModel: Model<EmployeeDocument>,
) {}
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,
},
};
}
}
+22
View File
@@ -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);
}
+24
View File
@@ -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"]
}
+60
View File
@@ -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:
@@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
announcementsApi.getAll().then((res) => {
setAnnouncements(res.data);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Announcements" />
<div className="p-6 max-w-3xl">
{loading ? (
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div></div>
) : announcements.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-5xl mb-3">📢</p>
<p className="font-medium">No announcements at this time</p>
</div>
) : (
<div className="space-y-4">
{announcements.map((ann: any) => (
<div key={ann._id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{ann.title}</h3>
<p className="text-sm text-gray-600 mt-2">{ann.content}</p>
</div>
<span className="px-2 py-1 bg-indigo-100 text-indigo-700 text-xs rounded-full flex-shrink-0">
{ann.targetAudience === 'all' ? 'All' : ann.targetDepartment?.name}
</span>
</div>
<div className="mt-3 flex items-center gap-3 text-xs text-gray-400">
<span>By {ann.createdBy?.firstName} {ann.createdBy?.lastName}</span>
<span></span>
<span>{formatDate(ann.createdAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -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<string, string> = {
present: 'bg-green-500',
absent: 'bg-red-400',
wfh: 'bg-blue-500',
half_day: 'bg-yellow-500',
holiday: 'bg-purple-500',
};
const statusLabels: Record<string, string> = {
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<any[]>([]);
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<string, number>);
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Attendance" />
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
{/* Month Selector */}
<div className="flex items-center justify-between mb-5">
<h3 className="font-semibold text-gray-800">{monthNames[month]} {year}</h3>
<div className="flex gap-2">
<select
value={month}
onChange={(e) => setMonth(Number(e.target.value))}
className="text-sm border border-gray-300 rounded-lg px-2 py-1"
>
{monthNames.slice(1).map((m, i) => (
<option key={i + 1} value={i + 1}>{m}</option>
))}
</select>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="text-sm border border-gray-300 rounded-lg px-2 py-1"
>
{[2023, 2024, 2025, 2026].map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
{/* Calendar */}
<div className="grid grid-cols-7 gap-1 mb-2">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="text-center text-xs font-medium text-gray-400 py-2">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: firstDay }).map((_, i) => (
<div key={`empty-${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 (
<div
key={day}
className={`aspect-square rounded-lg flex items-center justify-center text-xs font-medium relative
${isToday ? 'ring-2 ring-indigo-500' : ''}
${record ? statusColors[record.status] + ' text-white' : isWeekend ? 'bg-gray-50 text-gray-400' : 'bg-gray-100 text-gray-500'}
`}
>
{day}
</div>
);
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 mt-4">
{Object.entries(statusColors).map(([status, color]) => (
<div key={status} className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${color}`}></div>
<span className="text-xs text-gray-500">{statusLabels[status]}</span>
</div>
))}
</div>
</div>
{/* Summary Sidebar */}
<div className="space-y-4">
{/* Mark Today */}
{!markedToday && month === today.getMonth() + 1 && year === today.getFullYear() && (
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-5">
<p className="text-sm font-semibold text-indigo-800 mb-2">Mark Today&apos;s Attendance</p>
<p className="text-xs text-indigo-600 mb-3">You haven&apos;t marked attendance for today.</p>
<button
onClick={handleMark}
disabled={marking}
className="w-full bg-indigo-600 text-white text-sm py-2 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{marking ? 'Marking...' : 'Mark Present'}
</button>
</div>
)}
{markedToday && (
<div className="bg-green-50 border border-green-200 rounded-xl p-5">
<p className="text-sm font-semibold text-green-800">Attendance Marked!</p>
<p className="text-xs text-green-600 mt-1">You are marked as present today.</p>
</div>
)}
{/* Monthly Summary */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Monthly Summary</h3>
<div className="space-y-3">
{Object.entries(statusLabels).map(([status, label]) => (
<div key={status} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${statusColors[status]}`}></div>
<span className="text-sm text-gray-600">{label}</span>
</div>
<span className="text-sm font-bold text-gray-800">{summary[status] || 0}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+168
View File
@@ -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<any>(null);
const [attendance, setAttendance] = useState<any[]>([]);
const [reimbursements, setReimbursements] = useState<any[]>([]);
const [announcements, setAnnouncements] = useState<any[]>([]);
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 (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Dashboard" />
<div className="p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-800">
Good {today.getHours() < 12 ? 'morning' : today.getHours() < 17 ? 'afternoon' : 'evening'},{' '}
{user?.firstName}!
</h2>
<p className="text-sm text-gray-500">{formatDate(today)}</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div data-testid="leave-balance-card" className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Leave Balance</p>
<div className="mt-2 flex items-end gap-2">
<p className="text-3xl font-bold text-indigo-600">
{leaveBalance?.balance?.casual || 0}
</p>
<p className="text-sm text-gray-500 mb-1">casual days</p>
</div>
<div className="mt-2 text-xs text-gray-500">
Sick: {leaveBalance?.balance?.sick || 0} | Earned: {leaveBalance?.balance?.earned || 0}
</div>
</div>
<div data-testid="attendance-card" className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Attendance This Month</p>
<p className="text-3xl font-bold text-green-600 mt-2">{attendancePct}%</p>
<p className="text-xs text-gray-500 mt-2">{presentDays} of {totalDays} days</p>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Pending Reimbursements</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">{reimbursements.length}</p>
<p className="text-xs text-gray-500 mt-2">Awaiting approval</p>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Today&apos;s Status</p>
<p className={`text-xl font-bold mt-2 ${markedToday ? 'text-green-600' : 'text-gray-400'}`}>
{markedToday ? 'Marked Present' : 'Not Marked'}
</p>
{!markedToday && (
<button
onClick={handleMarkAttendance}
disabled={markingAttendance}
className="mt-2 text-xs bg-indigo-600 text-white px-3 py-1.5 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{markingAttendance ? 'Marking...' : 'Mark Present'}
</button>
)}
</div>
</div>
{/* Quick Actions & Announcements */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3>
<div className="space-y-2">
<Link href="/leave" className="flex items-center gap-3 p-3 rounded-lg bg-indigo-50 hover:bg-indigo-100 transition text-indigo-700 text-sm font-medium">
<span>🏖</span> Apply for Leave
</Link>
<Link href="/reimbursements" className="flex items-center gap-3 p-3 rounded-lg bg-blue-50 hover:bg-blue-100 transition text-blue-700 text-sm font-medium">
<span>📄</span> Submit Reimbursement
</Link>
<Link href="/payslips" className="flex items-center gap-3 p-3 rounded-lg bg-green-50 hover:bg-green-100 transition text-green-700 text-sm font-medium">
<span>💰</span> View Payslips
</Link>
<Link href="/tax" className="flex items-center gap-3 p-3 rounded-lg bg-purple-50 hover:bg-purple-100 transition text-purple-700 text-sm font-medium">
<span>🧾</span> Tax Declaration
</Link>
</div>
</div>
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Announcements</h3>
{announcements.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">No announcements</p>
) : (
<div className="space-y-3">
{announcements.map((ann: any) => (
<div key={ann._id} className="flex gap-3 p-3 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-indigo-100 rounded-lg flex items-center justify-center text-indigo-600 text-sm flex-shrink-0">
📢
</div>
<div>
<p className="text-sm font-medium text-gray-800">{ann.title}</p>
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{ann.content}</p>
<p className="text-xs text-gray-400 mt-1">{formatDate(ann.createdAt)}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+37
View File
@@ -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 (
<div className="flex items-center justify-center min-h-screen bg-[#F8FAFC]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
if (!user) return null;
return (
<div className="flex min-h-screen bg-[#F8FAFC]">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
+199
View File
@@ -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<any>(null);
const [leaves, setLeaves] = useState<any[]>([]);
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 (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Leave Management" />
<div className="p-6">
{/* Balance Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
{balanceItems.map((item) => (
<div key={item.key} className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{item.label}</p>
<p className={`text-4xl font-bold text-${item.color}-600 mt-2`}>
{balance?.balance?.[item.key] || 0}
</p>
<p className="text-xs text-gray-500 mt-1">days available</p>
</div>
))}
</div>
{/* Apply Leave */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800">My Leave Requests</h3>
<button
data-testid="apply-leave-btn"
onClick={() => setShowForm(!showForm)}
className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
>
+ Apply Leave
</button>
</div>
{showForm && (
<form onSubmit={handleApply} className="bg-gray-50 rounded-xl p-5 mb-5 border border-gray-200">
<h4 className="font-semibold text-gray-700 mb-4">New Leave Application</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Leave Type</label>
<select
value={form.leaveType}
onChange={(e) => setForm({ ...form, leaveType: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="casual">Casual Leave</option>
<option value="sick">Sick Leave</option>
<option value="earned">Earned Leave</option>
<option value="lop">Leave Without Pay</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">From Date</label>
<input
type="date"
value={form.fromDate}
onChange={(e) => setForm({ ...form, fromDate: e.target.value })}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">To Date</label>
<input
type="date"
value={form.toDate}
onChange={(e) => setForm({ ...form, toDate: e.target.value })}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Reason</label>
<input
type="text"
value={form.reason}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex gap-3 mt-4">
<button
type="submit"
disabled={submitting}
className="bg-indigo-600 text-white text-sm px-5 py-2 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{submitting ? 'Submitting...' : 'Submit Application'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="text-gray-600 text-sm px-5 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition"
>
Cancel
</button>
</div>
</form>
)}
{/* Leave History */}
{loading ? (
<div className="text-center py-8"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div></div>
) : leaves.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<p className="text-4xl mb-2">🏖</p>
<p className="font-medium">No leave requests yet</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left py-3 text-gray-500 font-medium">Type</th>
<th className="text-left py-3 text-gray-500 font-medium">From</th>
<th className="text-left py-3 text-gray-500 font-medium">To</th>
<th className="text-left py-3 text-gray-500 font-medium">Days</th>
<th className="text-left py-3 text-gray-500 font-medium">Reason</th>
<th className="text-left py-3 text-gray-500 font-medium">Status</th>
</tr>
</thead>
<tbody>
{leaves.map((leave: any) => (
<tr key={leave._id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="py-3 font-medium capitalize">{leave.leaveType}</td>
<td className="py-3">{formatDate(leave.fromDate)}</td>
<td className="py-3">{formatDate(leave.toDate)}</td>
<td className="py-3">{leave.numberOfDays}</td>
<td className="py-3 max-w-xs truncate">{leave.reason}</td>
<td className="py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(leave.status)}`}>
{leave.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
+150
View File
@@ -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<any[]>([]);
const [selected, setSelected] = useState<any>(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 (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Payslips" />
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Payslip List */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Payslip History</h3>
{loading ? (
<div className="text-center py-8"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600 mx-auto"></div></div>
) : payslips.length === 0 ? (
<p className="text-center py-8 text-gray-500 text-sm">No payslips generated yet</p>
) : (
<div className="space-y-2">
{payslips.map((ps: any) => (
<button
key={ps._id}
onClick={() => setSelected(ps)}
className={`w-full text-left px-4 py-3 rounded-lg border transition ${
selected?._id === ps._id
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<p className="font-medium text-sm text-gray-800">{monthNames[ps.month]} {ps.year}</p>
<p className="text-xs text-gray-500 mt-0.5">{formatCurrency(ps.netPay)}</p>
</button>
))}
</div>
)}
</div>
{/* Payslip Detail */}
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
{!selected ? (
<div className="flex items-center justify-center h-64 text-gray-500">
<div className="text-center">
<p className="text-4xl mb-2">💰</p>
<p>Select a payslip to view details</p>
</div>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="font-bold text-gray-800 text-lg">{monthNames[selected.month]} {selected.year}</h3>
<p className="text-sm text-gray-500">Pay Period</p>
</div>
<button
onClick={() => handleDownload(selected._id)}
disabled={downloading}
className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{downloading ? 'Downloading...' : 'Download PDF'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Earnings */}
<div className="bg-green-50 rounded-xl p-4">
<h4 className="font-semibold text-green-800 mb-3">Earnings</h4>
<div className="space-y-2">
{[
{ 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) => (
<div key={item.label} className="flex justify-between text-sm">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium text-gray-800">{formatCurrency(item.value || 0)}</span>
</div>
))}
<div className="flex justify-between text-sm font-bold border-t border-green-200 pt-2 mt-2">
<span className="text-green-800">Gross Pay</span>
<span className="text-green-800">{formatCurrency(selected.earnings?.gross || 0)}</span>
</div>
</div>
</div>
{/* Deductions */}
<div className="bg-red-50 rounded-xl p-4">
<h4 className="font-semibold text-red-800 mb-3">Deductions</h4>
<div className="space-y-2">
{[
{ label: 'PF (Employee)', value: selected.deductions?.pfEmployee },
{ label: 'Professional Tax', value: selected.deductions?.professionalTax },
{ label: 'TDS', value: selected.deductions?.tds },
].map((item) => (
<div key={item.label} className="flex justify-between text-sm">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium text-gray-800">{formatCurrency(item.value || 0)}</span>
</div>
))}
<div className="flex justify-between text-sm font-bold border-t border-red-200 pt-2 mt-2">
<span className="text-red-800">Total Deductions</span>
<span className="text-red-800">{formatCurrency(selected.deductions?.totalDeductions || 0)}</span>
</div>
</div>
</div>
</div>
{/* Net Pay */}
<div className="mt-4 bg-indigo-600 rounded-xl p-4 text-white flex items-center justify-between">
<span className="font-semibold text-lg">Net Pay</span>
<span className="font-bold text-2xl">{formatCurrency(selected.netPay || 0)}</span>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+110
View File
@@ -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<any>(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 (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="My Profile" />
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
</div>
);
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="My Profile" />
<div className="p-6 max-w-4xl">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 mb-6">
<div className="flex items-center gap-4 mb-6">
<div className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold text-2xl">
{profile?.firstName?.[0]}{profile?.lastName?.[0]}
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">{profile?.firstName} {profile?.lastName}</h2>
<p className="text-gray-500">{profile?.designation}</p>
<span className="inline-block mt-1 px-3 py-1 bg-indigo-100 text-indigo-700 text-xs rounded-full font-medium capitalize">
{profile?.role?.replace('_', ' ')}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Personal Info</h3>
<div className="space-y-3">
{[
{ 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) => (
<div key={item.label} className="flex gap-3">
<span className="text-sm text-gray-500 w-32 flex-shrink-0">{item.label}</span>
<span className="text-sm text-gray-800 font-medium">{item.value || 'N/A'}</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Work Info</h3>
<div className="space-y-3">
{[
{ 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) => (
<div key={item.label} className="flex gap-3">
<span className="text-sm text-gray-500 w-32 flex-shrink-0">{item.label}</span>
<span className="text-sm text-gray-800 font-medium">{item.value || 'N/A'}</span>
</div>
))}
</div>
</div>
</div>
</div>
{profile?.salaryStructure && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Salary Structure</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ 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) => (
<div key={item.label} className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">{item.label}</p>
<p className="text-lg font-bold text-gray-800 mt-1">
{(item.value || 0).toLocaleString()}
</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
@@ -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<any[]>([]);
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<File | null>(null);
const fileRef = useRef<HTMLInputElement>(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 (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Reimbursements" />
<div className="p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800">My Reimbursements</h3>
<button
onClick={() => setShowForm(!showForm)}
className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
>
+ New Claim
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="bg-gray-50 rounded-xl p-5 mb-5 border border-gray-200">
<h4 className="font-semibold text-gray-700 mb-4">Submit Reimbursement Claim</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="travel">Travel</option>
<option value="food">Food</option>
<option value="medical">Medical</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Amount ()</label>
<input
type="number"
min="1"
value={form.amount}
onChange={(e) => 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"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
required
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Describe the expense"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Receipt (PDF/JPG/PNG, max 5MB)</label>
<input
ref={fileRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={(e) => setReceipt(e.target.files?.[0] || null)}
className="w-full text-sm text-gray-500"
/>
</div>
</div>
<div className="flex gap-3 mt-4">
<button type="submit" disabled={submitting} className="bg-indigo-600 text-white text-sm px-5 py-2 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50">
{submitting ? 'Submitting...' : 'Submit Claim'}
</button>
<button type="button" onClick={() => setShowForm(false)} className="text-gray-600 text-sm px-5 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition">
Cancel
</button>
</div>
</form>
)}
{loading ? (
<div className="text-center py-8"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600 mx-auto"></div></div>
) : reimbursements.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<p className="text-4xl mb-2">📄</p>
<p className="font-medium">No reimbursement claims yet</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left py-3 text-gray-500 font-medium">Category</th>
<th className="text-left py-3 text-gray-500 font-medium">Amount</th>
<th className="text-left py-3 text-gray-500 font-medium">Description</th>
<th className="text-left py-3 text-gray-500 font-medium">Date</th>
<th className="text-left py-3 text-gray-500 font-medium">Status</th>
<th className="text-left py-3 text-gray-500 font-medium">Comment</th>
</tr>
</thead>
<tbody>
{reimbursements.map((r: any) => (
<tr key={r._id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="py-3 capitalize">{r.category}</td>
<td className="py-3 font-medium">{formatCurrency(r.amount)}</td>
<td className="py-3 max-w-xs truncate">{r.description}</td>
<td className="py-3">{formatDate(r.createdAt)}</td>
<td className="py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(r.status)}`}>
{r.status}
</span>
</td>
<td className="py-3 text-gray-500 max-w-xs truncate">{r.reviewComment || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { taxApi } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function TaxPage() {
const { user } = useAuth();
const [declaration, setDeclaration] = useState<any>(null);
const [projection, setProjection] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
section80C: '',
section80D: '',
hra: '',
lta: '',
homeLoanInterest: '',
otherDeductions: '',
});
useEffect(() => {
if (!user) return;
Promise.allSettled([
taxApi.getDeclaration(user._id),
taxApi.getTDSProjection(user._id),
]).then(([decRes, projRes]) => {
if (decRes.status === 'fulfilled') {
const dec = decRes.value.data;
setDeclaration(dec);
if (dec.declarations) {
setForm({
section80C: dec.declarations.section80C || '',
section80D: dec.declarations.section80D || '',
hra: dec.declarations.hra || '',
lta: dec.declarations.lta || '',
homeLoanInterest: dec.declarations.homeLoanInterest || '',
otherDeductions: dec.declarations.otherDeductions || '',
});
}
}
if (projRes.status === 'fulfilled') setProjection(projRes.value.data);
setLoading(false);
});
}, [user]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await taxApi.submitDeclaration({
section80C: Number(form.section80C) || 0,
section80D: Number(form.section80D) || 0,
hra: Number(form.hra) || 0,
lta: Number(form.lta) || 0,
homeLoanInterest: Number(form.homeLoanInterest) || 0,
otherDeductions: Number(form.otherDeductions) || 0,
});
const projRes = await taxApi.getTDSProjection(user!._id);
setProjection(projRes.data);
alert('Tax declaration submitted successfully!');
} catch (err: any) {
alert(err.response?.data?.message || 'Failed to submit declaration');
}
setSubmitting(false);
};
const fields = [
{ key: 'section80C', label: 'Section 80C (PF, ELSS, PPF, etc.)', max: 150000 },
{ key: 'section80D', label: 'Section 80D (Health Insurance)', max: 25000 },
{ key: 'hra', label: 'HRA Exemption', max: null },
{ key: 'lta', label: 'LTA Exemption', max: null },
{ key: 'homeLoanInterest', label: 'Home Loan Interest (24b)', max: 200000 },
{ key: 'otherDeductions', label: 'Other Deductions', max: null },
];
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Tax & Declarations" />
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div></div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Declaration Form */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-1">Investment Declaration</h3>
<p className="text-xs text-gray-500 mb-4">FY: {declaration?.financialYear}</p>
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
{field.max && <span className="text-gray-400"> (max {field.max.toLocaleString()})</span>}
</label>
<input
type="number"
min="0"
max={field.max || undefined}
value={(form as any)[field.key]}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="0"
/>
</div>
))}
<button
type="submit"
disabled={submitting}
className="w-full bg-indigo-600 text-white text-sm py-2.5 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save & Update Declaration'}
</button>
</form>
</div>
{/* TDS Projection */}
{projection && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">TDS Projection</h3>
<div className="space-y-3">
{[
{ label: 'Annual Gross Salary', value: projection.annualGross, color: 'text-gray-800' },
{ label: 'Total Deductions', value: projection.totalDeductions, color: 'text-green-600' },
{ label: 'Taxable Income', value: projection.taxableIncome, color: 'text-orange-600' },
{ label: 'Annual Tax (incl. cess)', value: projection.annualTax, color: 'text-red-600' },
].map((item) => (
<div key={item.label} className="flex justify-between items-center border-b border-gray-50 pb-2">
<span className="text-sm text-gray-500">{item.label}</span>
<span className={`font-semibold text-sm ${item.color}`}>{formatCurrency(item.value)}</span>
</div>
))}
<div className="bg-indigo-600 rounded-lg p-4 text-white mt-2">
<p className="text-sm opacity-80">Monthly TDS Deduction</p>
<p className="text-2xl font-bold mt-1">{formatCurrency(projection.monthlyTDS)}</p>
</div>
</div>
{/* Deduction Breakdown */}
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Deduction Breakdown</p>
{projection.breakdown && Object.entries(projection.breakdown).map(([key, val]: any) => (
<div key={key} className="flex justify-between text-xs text-gray-500 py-1">
<span className="capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}</span>
<span>{formatCurrency(val)}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useEffect, useState } from 'react';
import { announcementsApi, departmentsApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function AdminAnnouncementsPage() {
const [announcements, setAnnouncements] = useState<any[]>([]);
const [departments, setDepartments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ title: '', content: '', targetAudience: 'all', targetDepartment: '' });
const fetchData = async () => {
const [annRes, deptRes] = await Promise.allSettled([announcementsApi.getAll(), departmentsApi.getAll()]);
if (annRes.status === 'fulfilled') setAnnouncements(annRes.value.data);
if (deptRes.status === 'fulfilled') setDepartments(deptRes.value.data);
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await announcementsApi.create({ ...form, targetDepartment: form.targetDepartment || undefined });
setShowForm(false);
setForm({ title: '', content: '', targetAudience: 'all', targetDepartment: '' });
await fetchData();
} catch (err: any) { alert(err.response?.data?.message || 'Failed'); }
setSubmitting(false);
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this announcement?')) return;
try {
await announcementsApi.delete(id);
await fetchData();
} catch { alert('Failed to delete'); }
};
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Announcements" />
<div className="p-6 max-w-3xl">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-800">Manage Announcements</h3>
<button onClick={() => setShowForm(!showForm)} className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition">
+ New Announcement
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 mb-5">
<div className="space-y-3">
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required placeholder="Title" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
<textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} required rows={3} placeholder="Content" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
<div className="flex gap-3">
<select value={form.targetAudience} onChange={(e) => setForm({ ...form, targetAudience: e.target.value })} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="all">All Employees</option>
<option value="department">Specific Department</option>
</select>
{form.targetAudience === 'department' && (
<select value={form.targetDepartment} onChange={(e) => setForm({ ...form, targetDepartment: e.target.value })} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">Select Department</option>
{departments.map((d: any) => <option key={d._id} value={d._id}>{d.name}</option>)}
</select>
)}
</div>
<div className="flex gap-3">
<button type="submit" disabled={submitting} className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50">
{submitting ? 'Posting...' : 'Post Announcement'}
</button>
<button type="button" onClick={() => setShowForm(false)} className="text-gray-600 text-sm px-4 py-2 rounded-lg border border-gray-300">Cancel</button>
</div>
</div>
</form>
)}
{loading ? (
<div className="text-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div></div>
) : (
<div className="space-y-3">
{announcements.map((ann: any) => (
<div key={ann._id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div className="flex justify-between items-start">
<div className="flex-1">
<h4 className="font-semibold text-gray-900">{ann.title}</h4>
<p className="text-sm text-gray-600 mt-1">{ann.content}</p>
<p className="text-xs text-gray-400 mt-2">{formatDate(ann.createdAt)} · {ann.targetAudience === 'all' ? 'All Employees' : ann.targetDepartment?.name}</p>
</div>
<button onClick={() => handleDelete(ann._id)} className="text-red-500 hover:text-red-700 text-xs ml-4 mt-1">Delete</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { useEffect, useState } from 'react';
import { attendanceApi, employeesApi } from '@/lib/api';
import { getDaysInMonth, monthNames, getStatusColor } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function AdminAttendancePage() {
const today = new Date();
const [employees, setEmployees] = useState<any[]>([]);
const [attendance, setAttendance] = useState<any[]>([]);
const [selectedEmp, setSelectedEmp] = useState<string>('');
const [month, setMonth] = useState(today.getMonth() + 1);
const [year, setYear] = useState(today.getFullYear());
const [loading, setLoading] = useState(false);
const [editRecord, setEditRecord] = useState<any>(null);
const [editStatus, setEditStatus] = useState('present');
const [editNotes, setEditNotes] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
employeesApi.getAll({ limit: 100 }).then((res) => {
setEmployees(res.data.data || []);
});
}, []);
useEffect(() => {
if (!selectedEmp) return;
setLoading(true);
attendanceApi.getAll({ employeeId: selectedEmp, month, year }).then((res) => {
setAttendance(res.data);
setLoading(false);
}).catch(() => setLoading(false));
}, [selectedEmp, month, year]);
const getRecord = (day: number) => {
return attendance.find((r) => {
const d = new Date(r.date);
return d.getDate() === day && d.getMonth() + 1 === month && d.getFullYear() === year;
});
};
const handleOverride = async () => {
if (!editRecord) return;
setSaving(true);
try {
await attendanceApi.update(editRecord._id, { status: editStatus, notes: editNotes });
const res = await attendanceApi.getAll({ employeeId: selectedEmp, month, year });
setAttendance(res.data);
setEditRecord(null);
} catch (err: any) { alert(err.response?.data?.message || 'Failed'); }
setSaving(false);
};
const daysInMonth = getDaysInMonth(year, month);
const firstDay = new Date(year, month - 1, 1).getDay();
const statusColors: Record<string, string> = {
present: 'bg-green-500', absent: 'bg-red-400', wfh: 'bg-blue-500', half_day: 'bg-yellow-500', holiday: 'bg-purple-500',
};
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Attendance Management" />
<div className="p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div className="flex flex-wrap gap-3 mb-5">
<select value={selectedEmp} onChange={(e) => setSelectedEmp(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">Select Employee</option>
{employees.map((emp: any) => (
<option key={emp._id} value={emp._id}>{emp.firstName} {emp.lastName} ({emp.employeeId})</option>
))}
</select>
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
{monthNames.slice(1).map((m, i) => <option key={i + 1} value={i + 1}>{m}</option>)}
</select>
<select value={year} onChange={(e) => setYear(Number(e.target.value))} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
{[2023, 2024, 2025, 2026].map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{!selectedEmp ? (
<p className="text-center py-12 text-gray-500">Select an employee to view attendance</p>
) : loading ? (
<div className="text-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div></div>
) : (
<>
<div className="grid grid-cols-7 gap-1 mb-2">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="text-center text-xs font-medium text-gray-400 py-2">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: firstDay }).map((_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map((day) => {
const record = getRecord(day);
const isWeekend = new Date(year, month - 1, day).getDay() === 0 || new Date(year, month - 1, day).getDay() === 6;
return (
<button
key={day}
onClick={() => {
if (record) {
setEditRecord(record);
setEditStatus(record.status);
setEditNotes(record.notes || '');
}
}}
className={`aspect-square rounded-lg flex items-center justify-center text-xs font-medium
${record ? statusColors[record.status] + ' text-white hover:opacity-80' : isWeekend ? 'bg-gray-50 text-gray-400' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}
transition cursor-pointer`}
>
{day}
</button>
);
})}
</div>
</>
)}
</div>
{/* Edit Modal */}
{editRecord && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-sm w-full">
<h3 className="font-semibold text-gray-800 mb-4">Override Attendance</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select value={editStatus} onChange={(e) => setEditStatus(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
{['present', 'absent', 'wfh', 'half_day', 'holiday'].map((s) => <option key={s} value={s} className="capitalize">{s.replace('_', ' ')}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<input type="text" value={editNotes} onChange={(e) => setEditNotes(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
</div>
<div className="flex gap-3 mt-4">
<button onClick={handleOverride} disabled={saving} className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50">
{saving ? 'Saving...' : 'Save'}
</button>
<button onClick={() => setEditRecord(null)} className="text-gray-600 text-sm px-4 py-2 rounded-lg border border-gray-300">Cancel</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
import { useEffect, useState } from 'react';
import { employeesApi, leavesApi, reimbursementsApi, reportsApi } from '@/lib/api';
import { formatCurrency, formatDate, getStatusColor } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
import Link from 'next/link';
export default function AdminDashboard() {
const [stats, setStats] = useState({ totalEmployees: 0, pendingLeaves: 0, pendingReimb: 0 });
const [pendingLeaves, setPendingLeaves] = useState<any[]>([]);
const [pendingReimb, setPendingReimb] = useState<any[]>([]);
const [headcount, setHeadcount] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.allSettled([
employeesApi.getAll({ limit: 1 }),
leavesApi.getAll({ status: 'pending', limit: 5 }),
reimbursementsApi.getAll({ status: 'pending', limit: 5 }),
reportsApi.getHeadcount(),
]).then(([empRes, leaveRes, reimbRes, headRes]) => {
if (empRes.status === 'fulfilled') setStats((s) => ({ ...s, totalEmployees: empRes.value.data.total || 0 }));
if (leaveRes.status === 'fulfilled') {
const data = leaveRes.value.data.data || [];
setPendingLeaves(data);
setStats((s) => ({ ...s, pendingLeaves: leaveRes.value.data.total || data.length }));
}
if (reimbRes.status === 'fulfilled') {
const data = reimbRes.value.data.data || [];
setPendingReimb(data);
setStats((s) => ({ ...s, pendingReimb: reimbRes.value.data.total || data.length }));
}
if (headRes.status === 'fulfilled') setHeadcount(headRes.value.data);
setLoading(false);
});
}, []);
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="HR Admin Dashboard" />
<div className="p-6">
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div data-testid="headcount-card" className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Employees</p>
<p className="text-4xl font-bold text-indigo-600 mt-2">{stats.totalEmployees}</p>
<Link href="/admin/employees" className="text-xs text-indigo-600 hover:underline mt-1 inline-block">View all </Link>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Pending Leave Approvals</p>
<p className="text-4xl font-bold text-yellow-600 mt-2">{stats.pendingLeaves}</p>
<Link href="/admin/leaves" className="text-xs text-indigo-600 hover:underline mt-1 inline-block">Review </Link>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Pending Reimbursements</p>
<p className="text-4xl font-bold text-orange-600 mt-2">{stats.pendingReimb}</p>
<Link href="/admin/reimbursements" className="text-xs text-indigo-600 hover:underline mt-1 inline-block">Review </Link>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Pending Leave Approvals */}
<div data-testid="pending-approvals" className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800">Pending Leave Approvals</h3>
<Link href="/admin/leaves" className="text-sm text-indigo-600 hover:underline">View all</Link>
</div>
{pendingLeaves.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-6">No pending approvals</p>
) : (
<div className="space-y-3">
{pendingLeaves.slice(0, 4).map((leave: any) => {
const emp = leave.employeeId;
return (
<div key={leave._id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-800">
{emp?.firstName} {emp?.lastName}
</p>
<p className="text-xs text-gray-500">{leave.leaveType} · {leave.numberOfDays} days</p>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(leave.status)}`}>
{leave.status}
</span>
</div>
);
})}
</div>
)}
</div>
{/* Headcount by Dept */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Headcount by Department</h3>
{!headcount ? (
<div className="text-center py-6"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600 mx-auto"></div></div>
) : (
<div className="space-y-3">
{(headcount.byDepartment || []).map((dept: any) => (
<div key={dept._id} className="flex items-center gap-3">
<div className="flex-1">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-700">{dept._id || 'Unknown'}</span>
<span className="font-medium text-gray-800">{dept.count}</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full">
<div
className="h-1.5 bg-indigo-500 rounded-full"
style={{ width: `${Math.round((dept.count / headcount.total) * 100)}%` }}
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { employeesApi, departmentsApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function EmployeeDetailPage() {
const { id } = useParams();
const router = useRouter();
const [employee, setEmployee] = useState<any>(null);
const [departments, setDepartments] = useState<any[]>([]);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<any>({});
useEffect(() => {
Promise.all([
employeesApi.getOne(id as string),
departmentsApi.getAll(),
]).then(([empRes, deptRes]) => {
setEmployee(empRes.data);
setForm({
firstName: empRes.data.firstName,
lastName: empRes.data.lastName,
email: empRes.data.email,
phone: empRes.data.phone || '',
designation: empRes.data.designation || '',
department: empRes.data.department?._id || '',
salaryBasic: empRes.data.salaryStructure?.basic || 0,
salaryHRA: empRes.data.salaryStructure?.hra || 0,
salaryDA: empRes.data.salaryStructure?.da || 0,
salarySpecial: empRes.data.salaryStructure?.specialAllowance || 0,
});
setDepartments(deptRes.data);
setLoading(false);
}).catch(() => setLoading(false));
}, [id]);
const handleSave = async () => {
setSaving(true);
try {
const res = await employeesApi.update(id as string, {
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone,
designation: form.designation,
department: form.department || undefined,
salaryStructure: {
basic: Number(form.salaryBasic),
hra: Number(form.salaryHRA),
da: Number(form.salaryDA),
specialAllowance: Number(form.salarySpecial),
},
});
setEmployee(res.data);
setEditing(false);
} catch (err: any) {
alert(err.response?.data?.message || 'Failed to update');
}
setSaving(false);
};
if (loading) return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Employee Details" />
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div></div>
</div>
);
if (!employee) return null;
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Employee Details" />
<div className="p-6 max-w-4xl">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center text-indigo-600 font-bold text-xl">
{employee.firstName?.[0]}{employee.lastName?.[0]}
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">{employee.firstName} {employee.lastName}</h2>
<p className="text-gray-500 text-sm">{employee.employeeId} · {employee.designation}</p>
<span className={`inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium ${employee.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{employee.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<button
onClick={() => setEditing(!editing)}
className={`text-sm px-4 py-2 rounded-lg border transition ${editing ? 'border-gray-300 text-gray-600 hover:bg-gray-50' : 'bg-indigo-600 text-white hover:bg-indigo-700 border-transparent'}`}
>
{editing ? 'Cancel' : 'Edit'}
</button>
</div>
{!editing ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ label: 'Email', value: employee.email },
{ label: 'Phone', value: employee.phone },
{ label: 'Department', value: employee.department?.name },
{ label: 'Designation', value: employee.designation },
{ label: 'Date of Joining', value: employee.dateOfJoining ? formatDate(employee.dateOfJoining) : 'N/A' },
{ label: 'Role', value: employee.role?.replace('_', ' ') },
{ label: 'PAN Number', value: employee.panNumber },
{ label: 'Bank Account', value: employee.bankAccountNumber },
].map((item) => (
<div key={item.label} className="flex gap-3">
<span className="text-sm text-gray-500 w-36 flex-shrink-0">{item.label}</span>
<span className="text-sm text-gray-800 font-medium capitalize">{item.value || 'N/A'}</span>
</div>
))}
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ key: 'firstName', label: 'First Name', type: 'text' },
{ key: 'lastName', label: 'Last Name', type: 'text' },
{ key: 'email', label: 'Email', type: 'email' },
{ key: 'phone', label: 'Phone', type: 'tel' },
{ key: 'designation', label: 'Designation', type: 'text' },
].map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">{field.label}</label>
<input
type={field.type}
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
<select
value={form.department}
onChange={(e) => setForm({ ...form, department: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Select...</option>
{departments.map((d: any) => (
<option key={d._id} value={d._id}>{d.name}</option>
))}
</select>
</div>
</div>
<div className="border-t border-gray-100 pt-4">
<h4 className="font-medium text-gray-700 mb-3">Salary Structure</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ key: 'salaryBasic', label: 'Basic' },
{ key: 'salaryHRA', label: 'HRA' },
{ key: 'salaryDA', label: 'DA' },
{ key: 'salarySpecial', label: 'Special' },
].map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-gray-500 mb-1">{field.label} ()</label>
<input
type="number"
value={form[field.key] || 0}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
))}
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="bg-indigo-600 text-white text-sm px-5 py-2 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
)}
</div>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { employeesApi, departmentsApi } from '@/lib/api';
import Topbar from '@/components/layout/Topbar';
export default function NewEmployeePage() {
const router = useRouter();
const [departments, setDepartments] = useState<any[]>([]);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
employeeId: '',
firstName: '',
lastName: '',
email: '',
phone: '',
department: '',
designation: '',
dateOfJoining: '',
dateOfBirth: '',
role: 'employee',
password: '',
salaryBasic: '',
salaryHRA: '',
salaryDA: '',
salarySpecial: '',
});
useEffect(() => {
departmentsApi.getAll().then((res) => setDepartments(res.data));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await employeesApi.create({
employeeId: form.employeeId,
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone,
department: form.department || undefined,
designation: form.designation,
dateOfJoining: form.dateOfJoining || undefined,
dateOfBirth: form.dateOfBirth || undefined,
role: form.role,
password: form.password,
salaryStructure: {
basic: Number(form.salaryBasic) || 0,
hra: Number(form.salaryHRA) || 0,
da: Number(form.salaryDA) || 0,
specialAllowance: Number(form.salarySpecial) || 0,
},
});
router.push('/admin/employees');
} catch (err: any) {
alert(err.response?.data?.message || 'Failed to create employee');
}
setSubmitting(false);
};
const fields = [
{ key: 'employeeId', label: 'Employee ID', required: true, type: 'text', placeholder: 'EMP001' },
{ key: 'firstName', label: 'First Name', required: true, type: 'text' },
{ key: 'lastName', label: 'Last Name', required: true, type: 'text' },
{ key: 'email', label: 'Email', required: true, type: 'email' },
{ key: 'phone', label: 'Phone', required: false, type: 'tel' },
{ key: 'designation', label: 'Designation', required: false, type: 'text' },
{ key: 'dateOfJoining', label: 'Date of Joining', required: false, type: 'date' },
{ key: 'dateOfBirth', label: 'Date of Birth', required: false, type: 'date' },
{ key: 'password', label: 'Initial Password', required: true, type: 'password', placeholder: 'Min 6 characters' },
];
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Add New Employee" />
<div className="p-6 max-w-2xl">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Personal Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{fields.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{field.label} {field.required && <span className="text-red-500">*</span>}
</label>
<input
type={field.type}
value={(form as any)[field.key]}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
required={field.required}
placeholder={field.placeholder || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
<select
value={form.department}
onChange={(e) => setForm({ ...form, department: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Select Department</option>
{departments.map((d: any) => (
<option key={d._id} value={d._id}>{d.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="employee">Employee</option>
<option value="hr_admin">HR Admin</option>
</select>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Salary Structure</h3>
<div className="grid grid-cols-2 gap-4">
{[
{ key: 'salaryBasic', label: 'Basic Salary' },
{ key: 'salaryHRA', label: 'HRA' },
{ key: 'salaryDA', label: 'DA' },
{ key: 'salarySpecial', label: 'Special Allowance' },
].map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">{field.label} ()</label>
<input
type="number"
min="0"
value={(form as any)[field.key]}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="0"
/>
</div>
))}
</div>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={submitting}
className="bg-indigo-600 text-white text-sm px-6 py-2.5 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Employee'}
</button>
<button
type="button"
onClick={() => router.back()}
className="text-gray-600 text-sm px-6 py-2.5 rounded-lg border border-gray-300 hover:bg-gray-50 transition"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { employeesApi, departmentsApi } from '@/lib/api';
import { formatDate, getStatusColor } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
import Link from 'next/link';
export default function EmployeesPage() {
const router = useRouter();
const [employees, setEmployees] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [department, setDepartment] = useState('');
const [departments, setDepartments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchEmployees = async () => {
setLoading(true);
try {
const res = await employeesApi.getAll({ page, limit: 15, search, department });
setEmployees(res.data.data || []);
setTotal(res.data.total || 0);
} catch {}
setLoading(false);
};
useEffect(() => {
departmentsApi.getAll().then((res) => setDepartments(res.data));
}, []);
useEffect(() => { fetchEmployees(); }, [page, department]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
fetchEmployees();
};
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Employees" />
<div className="p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
{/* Header */}
<div className="p-5 border-b border-gray-100">
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
<form onSubmit={handleSearch} className="flex gap-2 flex-1">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, ID, email..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
<select
value={department}
onChange={(e) => { setDepartment(e.target.value); setPage(1); }}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">All Departments</option>
{departments.map((d: any) => (
<option key={d._id} value={d._id}>{d.name}</option>
))}
</select>
<button type="submit" className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition">
Search
</button>
</form>
<div className="flex gap-2">
<Link href="/admin/employees/new" className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition whitespace-nowrap">
+ Add Employee
</Link>
</div>
</div>
</div>
{/* Table */}
{loading ? (
<div className="text-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div></div>
) : employees.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-4xl mb-2">👥</p>
<p>No employees found</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50">
<th className="text-left px-5 py-3 text-gray-500 font-medium">Employee</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">ID</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Department</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Designation</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Joining Date</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Status</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{employees.map((emp: any) => (
<tr key={emp._id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="px-5 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center text-indigo-600 font-semibold text-xs">
{emp.firstName?.[0]}{emp.lastName?.[0]}
</div>
<div>
<p className="font-medium text-gray-800">{emp.firstName} {emp.lastName}</p>
<p className="text-xs text-gray-400">{emp.email}</p>
</div>
</div>
</td>
<td className="px-5 py-3 font-mono text-xs">{emp.employeeId}</td>
<td className="px-5 py-3">{emp.department?.name || '—'}</td>
<td className="px-5 py-3 text-gray-500">{emp.designation || '—'}</td>
<td className="px-5 py-3">{emp.dateOfJoining ? formatDate(emp.dateOfJoining) : '—'}</td>
<td className="px-5 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${emp.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{emp.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-5 py-3">
<Link href={`/admin/employees/${emp._id}`} className="text-indigo-600 hover:underline text-xs font-medium">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{total > 15 && (
<div className="p-5 flex items-center justify-between border-t border-gray-100">
<p className="text-sm text-gray-500">Showing {employees.length} of {total}</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50"
>Prev</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page * 15 >= total}
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50"
>Next</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+37
View File
@@ -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 AdminLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading) {
if (!user) {
router.push('/login');
} else if (user.role !== 'hr_admin' && user.role !== 'super_admin') {
router.push('/dashboard');
}
}
}, [user, loading, router]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-[#F8FAFC]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
if (!user || (user.role !== 'hr_admin' && user.role !== 'super_admin')) return null;
return (
<div className="flex min-h-screen bg-[#F8FAFC]">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
'use client';
import { useEffect, useState } from 'react';
import { leavesApi } from '@/lib/api';
import { formatDate, getStatusColor } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function LeaveApprovalsPage() {
const [leaves, setLeaves] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('pending');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [rejectModal, setRejectModal] = useState<{ id: string } | null>(null);
const [rejectReason, setRejectReason] = useState('');
const fetchLeaves = async () => {
setLoading(true);
try {
const res = await leavesApi.getAll({ status: filter, limit: 50 });
setLeaves(res.data.data || []);
} catch {}
setLoading(false);
};
useEffect(() => { fetchLeaves(); }, [filter]);
const handleApprove = async (id: string) => {
setActionLoading(id);
try {
await leavesApi.approve(id);
await fetchLeaves();
} catch (err: any) {
alert(err.response?.data?.message || 'Failed to approve');
}
setActionLoading(null);
};
const handleReject = async () => {
if (!rejectModal || !rejectReason.trim()) return;
setActionLoading(rejectModal.id);
try {
await leavesApi.reject(rejectModal.id, rejectReason);
setRejectModal(null);
setRejectReason('');
await fetchLeaves();
} catch (err: any) {
alert(err.response?.data?.message || 'Failed to reject');
}
setActionLoading(null);
};
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Leave Approvals" />
<div className="p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
<div className="p-5 border-b border-gray-100 flex gap-2">
{['pending', 'approved', 'rejected'].map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize transition ${filter === s ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{s}
</button>
))}
</div>
{loading ? (
<div className="text-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div></div>
) : leaves.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-4xl mb-2"></p>
<p>No {filter} leaves</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50">
<th className="text-left px-5 py-3 text-gray-500 font-medium">Employee</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Leave Type</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">From</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">To</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Days</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Reason</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Status</th>
{filter === 'pending' && <th className="text-left px-5 py-3 text-gray-500 font-medium">Actions</th>}
</tr>
</thead>
<tbody>
{leaves.map((leave: any) => {
const emp = leave.employeeId;
return (
<tr key={leave._id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="px-5 py-3">
<p className="font-medium">{emp?.firstName} {emp?.lastName}</p>
<p className="text-xs text-gray-400">{emp?.employeeId}</p>
</td>
<td className="px-5 py-3 capitalize">{leave.leaveType}</td>
<td className="px-5 py-3">{formatDate(leave.fromDate)}</td>
<td className="px-5 py-3">{formatDate(leave.toDate)}</td>
<td className="px-5 py-3">{leave.numberOfDays}</td>
<td className="px-5 py-3 max-w-xs truncate">{leave.reason}</td>
<td className="px-5 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(leave.status)}`}>
{leave.status}
</span>
{leave.reviewComment && (
<p className="text-xs text-gray-400 mt-1">{leave.reviewComment}</p>
)}
</td>
{filter === 'pending' && (
<td className="px-5 py-3">
<div className="flex gap-2">
<button
onClick={() => handleApprove(leave._id)}
disabled={actionLoading === leave._id}
className="bg-green-600 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-green-700 transition disabled:opacity-50"
>
Approve
</button>
<button
onClick={() => setRejectModal({ id: leave._id })}
className="bg-red-500 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-red-600 transition"
>
Reject
</button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Reject Modal */}
{rejectModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-md w-full">
<h3 className="font-semibold text-gray-800 mb-3">Reject Leave Request</h3>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Enter rejection reason (required)"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-4"
/>
<div className="flex gap-3">
<button
onClick={handleReject}
disabled={!rejectReason.trim() || !!actionLoading}
className="bg-red-500 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-600 disabled:opacity-50"
>
Confirm Reject
</button>
<button
onClick={() => { setRejectModal(null); setRejectReason(''); }}
className="text-gray-600 text-sm px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useState } from 'react';
import { payrollApi } from '@/lib/api';
import { formatCurrency, monthNames } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function PayrollPage() {
const today = new Date();
const [payrollRuns, setPayrollRuns] = useState<any[]>([]);
const [payslips, setPayslips] = useState<any[]>([]);
const [selectedRun, setSelectedRun] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [genMonth, setGenMonth] = useState(today.getMonth() + 1);
const [genYear, setGenYear] = useState(today.getFullYear());
const fetchPayroll = async () => {
try {
const res = await payrollApi.getPayrollRuns();
setPayrollRuns(res.data);
setLoading(false);
} catch { setLoading(false); }
};
useEffect(() => { fetchPayroll(); }, []);
const handleSelectRun = async (run: any) => {
setSelectedRun(run);
const res = await payrollApi.getPayslips({ month: run.month, year: run.year });
setPayslips(res.data);
};
const handleGenerate = async () => {
setGenerating(true);
try {
await payrollApi.generate(genMonth, genYear);
await fetchPayroll();
} catch (err: any) {
alert(err.response?.data?.message || 'Failed to generate payroll');
}
setGenerating(false);
};
const handleDownload = async (id: string) => {
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'); }
};
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Payroll Management" />
<div className="p-6">
{/* Generate Payroll */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 mb-6">
<h3 className="font-semibold text-gray-800 mb-4">Generate Payroll</h3>
<div className="flex items-center gap-3">
<select
value={genMonth}
onChange={(e) => setGenMonth(Number(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{monthNames.slice(1).map((m, i) => (
<option key={i + 1} value={i + 1}>{m}</option>
))}
</select>
<select
value={genYear}
onChange={(e) => setGenYear(Number(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{[2024, 2025, 2026].map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
<button
onClick={handleGenerate}
disabled={generating}
className="bg-indigo-600 text-white text-sm px-5 py-2 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Payroll'}
</button>
</div>
<p className="text-xs text-gray-400 mt-2">This will generate payslips for all active employees for the selected month.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Payroll Runs */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Payroll Runs</h3>
{loading ? (
<div className="text-center py-8"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600 mx-auto"></div></div>
) : payrollRuns.length === 0 ? (
<p className="text-sm text-center text-gray-500 py-6">No payroll runs yet</p>
) : (
<div className="space-y-2">
{payrollRuns.map((run: any) => (
<button
key={run._id}
onClick={() => handleSelectRun(run)}
className={`w-full text-left px-4 py-3 rounded-lg border transition ${selectedRun?._id === run._id ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:bg-gray-50'}`}
>
<p className="font-medium text-sm text-gray-800">{monthNames[run.month]} {run.year}</p>
<p className="text-xs text-gray-500">{run.totalEmployees} employees · {formatCurrency(run.totalNet)}</p>
</button>
))}
</div>
)}
</div>
{/* Payslips for selected run */}
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
{!selectedRun ? (
<div className="flex items-center justify-center h-64 text-gray-500">
<p>Select a payroll run to view payslips</p>
</div>
) : (
<>
<div className="mb-4">
<h3 className="font-semibold text-gray-800">{monthNames[selectedRun.month]} {selectedRun.year}</h3>
<div className="flex gap-4 mt-2 text-sm text-gray-500">
<span>Employees: {selectedRun.totalEmployees}</span>
<span>Total Gross: {formatCurrency(selectedRun.totalGross)}</span>
<span>Total Net: {formatCurrency(selectedRun.totalNet)}</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50">
<th className="text-left px-3 py-2 text-gray-500 font-medium">Employee</th>
<th className="text-right px-3 py-2 text-gray-500 font-medium">Gross</th>
<th className="text-right px-3 py-2 text-gray-500 font-medium">Deductions</th>
<th className="text-right px-3 py-2 text-gray-500 font-medium">Net Pay</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{payslips.map((ps: any) => {
const emp = ps.employeeId;
return (
<tr key={ps._id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="px-3 py-2">
<p className="font-medium">{emp?.firstName} {emp?.lastName}</p>
<p className="text-xs text-gray-400">{emp?.employeeId}</p>
</td>
<td className="px-3 py-2 text-right">{formatCurrency(ps.earnings?.gross)}</td>
<td className="px-3 py-2 text-right text-red-600">{formatCurrency(ps.deductions?.totalDeductions)}</td>
<td className="px-3 py-2 text-right font-bold text-green-700">{formatCurrency(ps.netPay)}</td>
<td className="px-3 py-2">
<button
onClick={() => handleDownload(ps._id)}
className="text-xs text-indigo-600 hover:underline"
>PDF</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
import { reimbursementsApi } from '@/lib/api';
import { formatDate, formatCurrency, getStatusColor } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function AdminReimbursementsPage() {
const [reimbursements, setReimbursements] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('pending');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [rejectModal, setRejectModal] = useState<{ id: string } | null>(null);
const [rejectReason, setRejectReason] = useState('');
const fetchData = async () => {
setLoading(true);
try {
const res = await reimbursementsApi.getAll({ status: filter, limit: 50 });
setReimbursements(res.data.data || []);
} catch {}
setLoading(false);
};
useEffect(() => { fetchData(); }, [filter]);
const handleApprove = async (id: string) => {
setActionLoading(id);
try {
await reimbursementsApi.approve(id);
await fetchData();
} catch (err: any) { alert(err.response?.data?.message || 'Failed'); }
setActionLoading(null);
};
const handleReject = async () => {
if (!rejectModal || !rejectReason.trim()) return;
setActionLoading(rejectModal.id);
try {
await reimbursementsApi.reject(rejectModal.id, rejectReason);
setRejectModal(null);
setRejectReason('');
await fetchData();
} catch (err: any) { alert(err.response?.data?.message || 'Failed'); }
setActionLoading(null);
};
const handleMarkPaid = async (id: string) => {
setActionLoading(id);
try {
await reimbursementsApi.markPaid(id);
await fetchData();
} catch (err: any) { alert(err.response?.data?.message || 'Failed'); }
setActionLoading(null);
};
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Reimbursements" />
<div className="p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
<div className="p-5 border-b border-gray-100 flex gap-2">
{['pending', 'approved', 'rejected', 'paid'].map((s) => (
<button key={s} onClick={() => setFilter(s)}
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize transition ${filter === s ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{s}
</button>
))}
</div>
{loading ? (
<div className="text-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div></div>
) : reimbursements.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-4xl mb-2">📄</p><p>No {filter} reimbursements</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50">
<th className="text-left px-5 py-3 text-gray-500 font-medium">Employee</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Category</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Amount</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Description</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Date</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Status</th>
<th className="text-left px-5 py-3 text-gray-500 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{reimbursements.map((r: any) => {
const emp = r.employeeId;
return (
<tr key={r._id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="px-5 py-3"><p className="font-medium">{emp?.firstName} {emp?.lastName}</p><p className="text-xs text-gray-400">{emp?.employeeId}</p></td>
<td className="px-5 py-3 capitalize">{r.category}</td>
<td className="px-5 py-3 font-medium">{formatCurrency(r.amount)}</td>
<td className="px-5 py-3 max-w-xs truncate">{r.description}</td>
<td className="px-5 py-3">{formatDate(r.createdAt)}</td>
<td className="px-5 py-3"><span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(r.status)}`}>{r.status}</span></td>
<td className="px-5 py-3">
<div className="flex gap-1">
{r.status === 'pending' && (
<>
<button onClick={() => handleApprove(r._id)} disabled={actionLoading === r._id} className="bg-green-600 text-white text-xs px-2 py-1 rounded hover:bg-green-700 disabled:opacity-50">Approve</button>
<button onClick={() => setRejectModal({ id: r._id })} className="bg-red-500 text-white text-xs px-2 py-1 rounded hover:bg-red-600">Reject</button>
</>
)}
{r.status === 'approved' && (
<button onClick={() => handleMarkPaid(r._id)} disabled={actionLoading === r._id} className="bg-blue-600 text-white text-xs px-2 py-1 rounded hover:bg-blue-700 disabled:opacity-50">Mark Paid</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{rejectModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-md w-full">
<h3 className="font-semibold text-gray-800 mb-3">Reject Reimbursement</h3>
<textarea value={rejectReason} onChange={(e) => setRejectReason(e.target.value)} placeholder="Reason (required)" rows={3} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-4" />
<div className="flex gap-3">
<button onClick={handleReject} disabled={!rejectReason.trim()} className="bg-red-500 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50">Confirm Reject</button>
<button onClick={() => { setRejectModal(null); setRejectReason(''); }} className="text-gray-600 text-sm px-4 py-2 rounded-lg border border-gray-300">Cancel</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
'use client';
import { useEffect, useState } from 'react';
import { reportsApi } from '@/lib/api';
import { formatCurrency, monthNames } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function ReportsPage() {
const today = new Date();
const [tab, setTab] = useState('headcount');
const [headcount, setHeadcount] = useState<any>(null);
const [payrollSummary, setPayrollSummary] = useState<any>(null);
const [leaveUtil, setLeaveUtil] = useState<any>(null);
const [attSummary, setAttSummary] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [month, setMonth] = useState(today.getMonth() + 1);
const [year, setYear] = useState(today.getFullYear());
const fetchHeadcount = async () => {
setLoading(true);
try { setHeadcount((await reportsApi.getHeadcount()).data); } catch {}
setLoading(false);
};
const fetchPayroll = async () => {
setLoading(true);
try { setPayrollSummary((await reportsApi.getPayrollSummary(month, year)).data); } catch {}
setLoading(false);
};
const fetchLeave = async () => {
setLoading(true);
try { setLeaveUtil((await reportsApi.getLeaveUtilization(year)).data); } catch {}
setLoading(false);
};
const fetchAttendance = async () => {
setLoading(true);
try { setAttSummary((await reportsApi.getAttendanceSummary(month, year)).data); } catch {}
setLoading(false);
};
useEffect(() => {
if (tab === 'headcount' && !headcount) fetchHeadcount();
if (tab === 'payroll') fetchPayroll();
if (tab === 'leave') fetchLeave();
if (tab === 'attendance') fetchAttendance();
}, [tab]);
const tabs = [
{ key: 'headcount', label: 'Headcount' },
{ key: 'payroll', label: 'Payroll Summary' },
{ key: 'leave', label: 'Leave Utilization' },
{ key: 'attendance', label: 'Attendance' },
];
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Reports" />
<div className="p-6">
<div className="flex gap-2 mb-6">
{tabs.map((t) => (
<button key={t.key} onClick={() => setTab(t.key)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${tab === t.key ? 'bg-indigo-600 text-white' : 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'}`}>
{t.label}
</button>
))}
</div>
{/* Month/Year selectors for relevant tabs */}
{(tab === 'payroll' || tab === 'attendance') && (
<div className="flex gap-2 mb-4">
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
{monthNames.slice(1).map((m, i) => <option key={i + 1} value={i + 1}>{m}</option>)}
</select>
<select value={year} onChange={(e) => setYear(Number(e.target.value))} className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
{[2024, 2025, 2026].map((y) => <option key={y} value={y}>{y}</option>)}
</select>
<button onClick={tab === 'payroll' ? fetchPayroll : fetchAttendance} className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg">
Load
</button>
</div>
)}
{tab === 'leave' && (
<div className="flex gap-2 mb-4">
<select value={year} onChange={(e) => setYear(Number(e.target.value))} className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
{[2024, 2025, 2026].map((y) => <option key={y} value={y}>{y}</option>)}
</select>
<button onClick={fetchLeave} className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg">Load</button>
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div></div>
) : (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
{tab === 'headcount' && headcount && (
<div>
<div className="flex items-center gap-4 mb-5">
<div className="text-center"><p className="text-4xl font-bold text-indigo-600">{headcount.total}</p><p className="text-sm text-gray-500">Total Employees</p></div>
</div>
<h4 className="font-semibold text-gray-700 mb-3">By Department</h4>
<div className="space-y-2">
{(headcount.byDepartment || []).map((d: any) => (
<div key={d._id} className="flex items-center gap-3">
<span className="text-sm text-gray-600 w-40">{d._id || 'Unknown'}</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full">
<div className="h-2 bg-indigo-500 rounded-full" style={{ width: `${Math.round((d.count / headcount.total) * 100)}%` }} />
</div>
<span className="text-sm font-bold text-gray-800 w-6 text-right">{d.count}</span>
</div>
))}
</div>
</div>
)}
{tab === 'payroll' && payrollSummary && (
<div>
<h4 className="font-semibold text-gray-800 mb-4">{monthNames[payrollSummary.month]} {payrollSummary.year} Summary</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Total Employees', value: payrollSummary.employeeCount, isCurrency: false },
{ label: 'Total Gross', value: payrollSummary.totalGross, isCurrency: true },
{ label: 'Total PF', value: payrollSummary.totalPF, isCurrency: true },
{ label: 'Total TDS', value: payrollSummary.totalTDS, isCurrency: true },
{ label: 'Total PT', value: payrollSummary.totalPT, isCurrency: true },
{ label: 'Total Net', value: payrollSummary.totalNet, isCurrency: true },
].map((item) => (
<div key={item.label} className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">{item.label}</p>
<p className="text-lg font-bold text-gray-800 mt-1">{item.isCurrency ? formatCurrency(item.value) : item.value}</p>
</div>
))}
</div>
</div>
)}
{tab === 'leave' && leaveUtil && (
<div>
<h4 className="font-semibold text-gray-800 mb-4">Leave Utilization {leaveUtil.year}</h4>
<div className="mb-4">
<p className="text-sm text-gray-500">Total days taken: <span className="font-bold">{leaveUtil.totalDays}</span></p>
<div className="flex gap-4 mt-2">
{Object.entries(leaveUtil.byType || {}).map(([type, days]: any) => (
<div key={type} className="bg-gray-50 rounded-lg px-3 py-2">
<p className="text-xs text-gray-500 capitalize">{type}</p>
<p className="font-bold text-gray-800">{days} days</p>
</div>
))}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-gray-100"><th className="text-left py-2 text-gray-500 font-medium">Employee</th><th className="text-center py-2">CL</th><th className="text-center py-2">SL</th><th className="text-center py-2">EL</th><th className="text-center py-2">LOP</th><th className="text-center py-2">Total</th></tr></thead>
<tbody>
{(leaveUtil.byEmployee || []).map((row: any, i: number) => (
<tr key={i} className="border-b border-gray-50">
<td className="py-2">{row.employee?.firstName} {row.employee?.lastName}</td>
<td className="text-center py-2">{row.casual || 0}</td>
<td className="text-center py-2">{row.sick || 0}</td>
<td className="text-center py-2">{row.earned || 0}</td>
<td className="text-center py-2">{row.lop || 0}</td>
<td className="text-center py-2 font-bold">{row.total}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tab === 'attendance' && attSummary && (
<div>
<h4 className="font-semibold text-gray-800 mb-4">Attendance Summary</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-gray-100"><th className="text-left py-2 text-gray-500 font-medium">Employee</th><th className="text-center py-2">Present</th><th className="text-center py-2">Absent</th><th className="text-center py-2">WFH</th><th className="text-center py-2">Half Day</th></tr></thead>
<tbody>
{(attSummary.byEmployee || []).map((row: any, i: number) => (
<tr key={i} className="border-b border-gray-50">
<td className="py-2">{row.employee?.firstName} {row.employee?.lastName}</td>
<td className="text-center py-2 text-green-600 font-medium">{row.present || 0}</td>
<td className="text-center py-2 text-red-500">{row.absent || 0}</td>
<td className="text-center py-2 text-blue-600">{row.wfh || 0}</td>
<td className="text-center py-2 text-yellow-600">{row.half_day || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--sidebar-bg: #1E293B;
--accent: #4F46E5;
--accent-light: #EEF2FF;
--page-bg: #F8FAFC;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--page-bg);
}
.sidebar-active {
background-color: var(--accent-light);
color: var(--accent);
border-left: 3px solid var(--accent);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { AuthProvider } from '@/lib/auth-context';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'HR Portal',
description: 'Human Resources Management Portal',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/lib/auth-context';
export default function LoginPage() {
const [employeeId, setEmployeeId] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(employeeId, password);
} catch (err: any) {
setError(err.response?.data?.message || 'Invalid credentials. Please check your Employee ID and password.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#F8FAFC] flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-600 rounded-2xl mb-4">
<span className="text-white font-bold text-2xl">HR</span>
</div>
<h1 className="text-3xl font-bold text-gray-900">HR Portal</h1>
<p className="text-gray-500 mt-1">Sign in to your account</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Employee ID
</label>
<input
data-testid="employee-id-input"
type="text"
value={employeeId}
onChange={(e) => setEmployeeId(e.target.value)}
placeholder="e.g. EMP001"
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Password
</label>
<input
data-testid="password-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
/>
</div>
{error && (
<div className="bg-red-50 text-red-700 text-sm px-4 py-3 rounded-lg border border-red-200">
{error}
</div>
)}
<button
data-testid="login-submit"
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-3 rounded-lg font-semibold text-sm hover:bg-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="mt-6 p-4 bg-blue-50 rounded-lg text-xs text-gray-600 space-y-1 border border-blue-100">
<p className="font-semibold text-gray-700 mb-2">Demo Credentials:</p>
<p><span className="font-medium">Super Admin:</span> ADMIN001 / Admin@123</p>
<p><span className="font-medium">HR Admin:</span> HR001 / Hr@12345</p>
<p><span className="font-medium">Employee:</span> EMP001 / Emp@12345</p>
</div>
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
export default function Home() {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading) {
if (user) {
if (user.role === 'super_admin') {
router.replace('/superadmin/dashboard');
} else if (user.role === 'hr_admin') {
router.replace('/admin/dashboard');
} else {
router.replace('/dashboard');
}
} else {
router.replace('/login');
}
}
}, [user, loading, router]);
return (
<div className="flex items-center justify-center min-h-screen bg-page-bg">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-accent"></div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
'use client';
import { useEffect, useState } from 'react';
import { adminApi, departmentsApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function AdminAccountsPage() {
const [accounts, setAccounts] = useState<any[]>([]);
const [departments, setDepartments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ employeeId: '', firstName: '', lastName: '', email: '', phone: '', department: '', password: '' });
const fetchData = async () => {
const [accRes, deptRes] = await Promise.allSettled([adminApi.getAccounts(), departmentsApi.getAll()]);
if (accRes.status === 'fulfilled') setAccounts(accRes.value.data || []);
if (deptRes.status === 'fulfilled') setDepartments(deptRes.value.data || []);
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await adminApi.createAccount({ ...form, department: form.department || undefined });
setShowForm(false);
setForm({ employeeId: '', firstName: '', lastName: '', email: '', phone: '', department: '', password: '' });
await fetchData();
} catch (err: any) { alert(err.response?.data?.message || 'Failed'); }
setSubmitting(false);
};
const handleDeactivate = async (id: string) => {
if (!confirm('Deactivate this account?')) return;
try { await adminApi.deactivateAccount(id); await fetchData(); } catch { alert('Failed'); }
};
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Admin Accounts" />
<div className="p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
<div className="p-5 border-b border-gray-100 flex justify-between items-center">
<h3 className="font-semibold text-gray-800">HR Admin Accounts</h3>
<button onClick={() => setShowForm(!showForm)} className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition">
+ Create Admin
</button>
</div>
{showForm && (
<div className="p-5 border-b border-gray-100 bg-gray-50">
<form onSubmit={handleCreate} className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
{ key: 'employeeId', label: 'Employee ID', type: 'text', required: true },
{ key: 'firstName', label: 'First Name', type: 'text', required: true },
{ key: 'lastName', label: 'Last Name', type: 'text', required: true },
{ key: 'email', label: 'Email', type: 'email', required: true },
{ key: 'phone', label: 'Phone', type: 'tel', required: false },
{ key: 'password', label: 'Password', type: 'password', required: true },
].map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-gray-700 mb-1">{field.label}</label>
<input type={field.type} required={field.required} value={(form as any)[field.key]} onChange={(e) => setForm({ ...form, [field.key]: e.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
))}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Department</label>
<select value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">Select...</option>
{departments.map((d: any) => <option key={d._id} value={d._id}>{d.name}</option>)}
</select>
</div>
<div className="md:col-span-2 flex gap-3">
<button type="submit" disabled={submitting} className="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50">{submitting ? 'Creating...' : 'Create Account'}</button>
<button type="button" onClick={() => setShowForm(false)} className="text-gray-600 text-sm px-4 py-2 rounded-lg border border-gray-300">Cancel</button>
</div>
</form>
</div>
)}
{loading ? (
<div className="text-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-gray-100 bg-gray-50">
{['Name', 'Employee ID', 'Email', 'Role', 'Status', 'Joined', 'Actions'].map((h) => (
<th key={h} className="text-left px-5 py-3 text-gray-500 font-medium">{h}</th>
))}
</tr></thead>
<tbody>
{accounts.map((acc: any) => (
<tr key={acc._id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="px-5 py-3 font-medium">{acc.firstName} {acc.lastName}</td>
<td className="px-5 py-3 font-mono text-xs">{acc.employeeId}</td>
<td className="px-5 py-3">{acc.email}</td>
<td className="px-5 py-3 capitalize">{acc.role?.replace('_', ' ')}</td>
<td className="px-5 py-3"><span className={`px-2 py-1 rounded-full text-xs font-medium ${acc.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>{acc.isActive ? 'Active' : 'Inactive'}</span></td>
<td className="px-5 py-3">{acc.dateOfJoining ? formatDate(acc.dateOfJoining) : '—'}</td>
<td className="px-5 py-3">
{acc.isActive && acc.role !== 'super_admin' && (
<button onClick={() => handleDeactivate(acc._id)} className="text-red-500 hover:text-red-700 text-xs font-medium">Deactivate</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
'use client';
import { useEffect, useState } from 'react';
import { adminApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
export default function AuditLogPage() {
const [logs, setLogs] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const fetchLogs = async () => {
setLoading(true);
try {
const res = await adminApi.getAuditLogs({ page, limit: 20 });
setLogs(res.data.data || []);
setTotal(res.data.total || 0);
} catch {}
setLoading(false);
};
useEffect(() => { fetchLogs(); }, [page]);
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Audit Log" />
<div className="p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
{loading ? (
<div className="text-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div></div>
) : logs.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-4xl mb-2">📋</p><p>No audit logs yet</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-gray-100 bg-gray-50">
{['Time', 'Action', 'Entity', 'Entity ID', 'Performed By'].map((h) => (
<th key={h} className="text-left px-5 py-3 text-gray-500 font-medium">{h}</th>
))}
</tr></thead>
<tbody>
{logs.map((log: any) => (
<tr key={log._id} className="border-b border-gray-50 hover:bg-gray-50">
<td className="px-5 py-3 text-gray-400 whitespace-nowrap">{formatDate(log.createdAt)}</td>
<td className="px-5 py-3 font-medium">{log.action}</td>
<td className="px-5 py-3">{log.entity}</td>
<td className="px-5 py-3 font-mono text-xs text-gray-400">{log.entityId || '—'}</td>
<td className="px-5 py-3">{log.performedBy ? `${log.performedBy.firstName} ${log.performedBy.lastName}` : 'System'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{total > 20 && (
<div className="p-5 flex items-center justify-between border-t border-gray-100">
<p className="text-sm text-gray-500">Showing page {page} of {Math.ceil(total / 20)}</p>
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50">Prev</button>
<button onClick={() => setPage((p) => p + 1)} disabled={page * 20 >= total} className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50">Next</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,85 @@
'use client';
import { useEffect, useState } from 'react';
import { adminApi, employeesApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import Topbar from '@/components/layout/Topbar';
import Link from 'next/link';
export default function SuperAdminDashboard() {
const [accounts, setAccounts] = useState<any[]>([]);
const [totalEmployees, setTotalEmployees] = useState(0);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.allSettled([
adminApi.getAccounts(),
employeesApi.getAll({ limit: 1 }),
adminApi.getAuditLogs({ limit: 5 }),
]).then(([accRes, empRes, logRes]) => {
if (accRes.status === 'fulfilled') setAccounts(accRes.value.data || []);
if (empRes.status === 'fulfilled') setTotalEmployees(empRes.value.data.total || 0);
if (logRes.status === 'fulfilled') setAuditLogs(logRes.value.data.data || []);
setLoading(false);
});
}, []);
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Super Admin Dashboard" />
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Employees</p>
<p className="text-4xl font-bold text-indigo-600 mt-2">{totalEmployees}</p>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">HR Admins</p>
<p className="text-4xl font-bold text-purple-600 mt-2">{accounts.filter((a) => a.role === 'hr_admin').length}</p>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">System Status</p>
<p className="text-xl font-bold text-green-600 mt-2">Operational</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-800">Admin Accounts</h3>
<Link href="/superadmin/accounts" className="text-sm text-indigo-600 hover:underline">Manage</Link>
</div>
{accounts.slice(0, 5).map((acc: any) => (
<div key={acc._id} className="flex items-center justify-between py-2 border-b border-gray-50 last:border-0">
<div>
<p className="text-sm font-medium text-gray-800">{acc.firstName} {acc.lastName}</p>
<p className="text-xs text-gray-400">{acc.employeeId} · {acc.role?.replace('_', ' ')}</p>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs ${acc.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{acc.isActive ? 'Active' : 'Inactive'}
</span>
</div>
))}
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-800">Recent Audit Logs</h3>
<Link href="/superadmin/audit-log" className="text-sm text-indigo-600 hover:underline">View all</Link>
</div>
{auditLogs.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No audit logs yet</p>
) : (
auditLogs.map((log: any) => (
<div key={log._id} className="py-2 border-b border-gray-50 last:border-0">
<p className="text-sm text-gray-800">{log.action} on {log.entity}</p>
<p className="text-xs text-gray-400">{formatDate(log.createdAt)}</p>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
'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 SuperAdminLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading) {
if (!user) router.push('/login');
else if (user.role !== 'super_admin') router.push('/dashboard');
}
}, [user, loading, router]);
if (loading || !user || user.role !== 'super_admin') {
return (
<div className="flex items-center justify-center min-h-screen bg-[#F8FAFC]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="flex min-h-screen bg-[#F8FAFC]">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import { useEffect, useState } from 'react';
import { adminApi } from '@/lib/api';
import Topbar from '@/components/layout/Topbar';
export default function OrgSettingsPage() {
const [settings, setSettings] = useState<any>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
adminApi.getOrgSettings().then((res) => {
setSettings(res.data);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await adminApi.updateOrgSettings(settings);
alert('Settings saved successfully!');
} catch { alert('Failed to save settings'); }
setSaving(false);
};
if (loading) return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Org Settings" />
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div></div>
</div>
);
return (
<div className="min-h-screen bg-[#F8FAFC]">
<Topbar title="Org Settings" />
<div className="p-6 max-w-2xl">
<form onSubmit={handleSave} className="space-y-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Company Information</h3>
<div className="space-y-4">
{[
{ key: 'companyName', label: 'Company Name', type: 'text' },
{ key: 'companyAddress', label: 'Address', type: 'text' },
{ key: 'companyCIN', label: 'CIN Number', type: 'text' },
{ key: 'companyGST', label: 'GST Number', type: 'text' },
{ key: 'state', label: 'State', type: 'text' },
].map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">{field.label}</label>
<input
type={field.type}
value={settings[field.key] || ''}
onChange={(e) => setSettings({ ...settings, [field.key]: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
))}
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Leave Policies</h3>
<div className="grid grid-cols-3 gap-4">
{[
{ key: 'casualLeavePerYear', label: 'Casual Leave/Year' },
{ key: 'sickLeavePerYear', label: 'Sick Leave/Year' },
{ key: 'earnedLeavePerYear', label: 'Earned Leave/Year' },
].map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">{field.label}</label>
<input
type="number"
value={settings.leavePolicies?.[field.key] || 0}
onChange={(e) => setSettings({
...settings,
leavePolicies: { ...(settings.leavePolicies || {}), [field.key]: Number(e.target.value) },
})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
))}
</div>
</div>
<button type="submit" disabled={saving} className="bg-indigo-600 text-white text-sm px-6 py-2.5 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50">
{saving ? 'Saving...' : 'Save Settings'}
</button>
</form>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
interface NavItem {
href: string;
label: string;
icon: string;
}
const employeeNav: NavItem[] = [
{ href: '/dashboard', label: 'Dashboard', icon: '⊞' },
{ href: '/profile', label: 'My Profile', icon: '👤' },
{ href: '/attendance', label: 'Attendance', icon: '📅' },
{ href: '/leave', label: 'Leave', icon: '🏖' },
{ href: '/payslips', label: 'Payslips', icon: '💰' },
{ href: '/reimbursements', label: 'Reimbursements', icon: '📄' },
{ href: '/tax', label: 'Tax & Form 16', icon: '🧾' },
{ href: '/announcements', label: 'Announcements', icon: '📢' },
];
const hrAdminNav: NavItem[] = [
{ href: '/admin/dashboard', label: 'Dashboard', icon: '⊞' },
{ href: '/admin/employees', label: 'Employees', icon: '👥' },
{ href: '/admin/attendance', label: 'Attendance', icon: '📅' },
{ href: '/admin/leaves', label: 'Leave Approvals', icon: '✅' },
{ href: '/admin/payroll', label: 'Payroll', icon: '💰' },
{ href: '/admin/reimbursements', label: 'Reimbursements', icon: '📄' },
{ href: '/admin/announcements', label: 'Announcements', icon: '📢' },
{ href: '/admin/reports', label: 'Reports', icon: '📊' },
];
const superAdminNav: NavItem[] = [
{ href: '/superadmin/dashboard', label: 'Dashboard', icon: '⊞' },
{ href: '/superadmin/accounts', label: 'Admin Accounts', icon: '🛡' },
{ href: '/superadmin/settings', label: 'Org Settings', icon: '⚙' },
{ href: '/superadmin/audit-log', label: 'Audit Log', icon: '📋' },
];
export default function Sidebar() {
const { user, logout } = useAuth();
const pathname = usePathname();
const navItems =
user?.role === 'super_admin'
? superAdminNav
: user?.role === 'hr_admin'
? hrAdminNav
: employeeNav;
return (
<aside
className="flex flex-col w-64 min-h-screen bg-[#1E293B] text-white shadow-xl"
style={{ backgroundColor: '#1E293B' }}
>
{/* Logo */}
<div className="flex items-center px-6 py-5 border-b border-slate-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">
HR
</div>
<span className="font-bold text-lg text-white">HR Portal</span>
</div>
</div>
{/* User Info */}
<div className="px-6 py-4 border-b border-slate-700">
<div className="w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-semibold mb-2">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</div>
<p className="text-sm font-semibold text-white truncate">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-slate-400 capitalize">
{user?.role?.replace('_', ' ')}
</p>
<p className="text-xs text-slate-500">{user?.employeeId}</p>
</div>
{/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/dashboard' && item.href !== '/admin/dashboard' && item.href !== '/superadmin/dashboard' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? 'bg-indigo-600 text-white'
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
}`}
>
<span className="text-base w-5 text-center">{item.icon}</span>
<span>{item.label}</span>
</Link>
);
})}
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t border-slate-700">
<button
onClick={logout}
className="flex items-center gap-3 px-3 py-2.5 w-full rounded-lg text-sm font-medium text-slate-300 hover:bg-slate-700 hover:text-white transition-all duration-150"
>
<span className="text-base w-5 text-center">🚪</span>
<span>Logout</span>
</button>
</div>
</aside>
);
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import { useAuth } from '@/lib/auth-context';
interface TopbarProps {
title: string;
}
export default function Topbar({ title }: TopbarProps) {
const { user } = useAuth();
return (
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-800">{title}</h1>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-sm font-medium text-gray-800">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-gray-500">{user?.email}</p>
</div>
<div className="w-9 h-9 bg-indigo-600 rounded-full flex items-center justify-center text-white font-semibold text-sm">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</div>
</div>
</header>
);
}
+180
View File
@@ -0,0 +1,180 @@
import axios from 'axios';
const BASE_URL = (process.env.NEXT_PUBLIC_API_URL || '/api-backend') + '/api/v1';
const api = axios.create({
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to attach JWT
api.interceptors.request.use(
(config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error),
);
// Response interceptor to handle 401 and refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (typeof window !== 'undefined') {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
try {
const response = await axios.post(`${BASE_URL}/auth/refresh`, {
refreshToken,
});
const { accessToken } = response.data;
localStorage.setItem('access_token', accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh failed, clear tokens and redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
}
}
}
return Promise.reject(error);
},
);
export default api;
// Auth
export const authApi = {
login: (employeeId: string, password: string) =>
api.post('/auth/login', { employeeId, password }),
logout: () => api.post('/auth/logout'),
changePassword: (currentPassword: string, newPassword: string) =>
api.post('/auth/change-password', { currentPassword, newPassword }),
getMe: () => api.get('/auth/me'),
};
// Employees
export const employeesApi = {
getAll: (params?: any) => api.get('/employees', { params }),
getOne: (id: string) => api.get(`/employees/${id}`),
create: (data: any) => api.post('/employees', data),
update: (id: string, data: any) => api.put(`/employees/${id}`, data),
delete: (id: string) => api.delete(`/employees/${id}`),
importCsv: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post('/employees/import-csv', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
// Departments
export const departmentsApi = {
getAll: () => api.get('/departments'),
create: (data: any) => api.post('/departments', data),
update: (id: string, data: any) => api.put(`/departments/${id}`, data),
delete: (id: string) => api.delete(`/departments/${id}`),
};
// Attendance
export const attendanceApi = {
mark: (data?: any) => api.post('/attendance', data || {}),
update: (id: string, data: any) => api.put(`/attendance/${id}`, data),
getAll: (params?: any) => api.get('/attendance', { params }),
};
// Leaves
export const leavesApi = {
apply: (data: any) => api.post('/leaves', data),
getAll: (params?: any) => api.get('/leaves', { params }),
approve: (id: string, comment?: string) =>
api.put(`/leaves/${id}/approve`, { comment }),
reject: (id: string, comment: string) =>
api.put(`/leaves/${id}/reject`, { comment }),
getBalance: (employeeId: string) => api.get(`/leaves/balance/${employeeId}`),
};
// Payroll
export const payrollApi = {
generate: (month: number, year: number) =>
api.post('/payroll/generate', { month, year }),
getPayrollRuns: (params?: any) => api.get('/payroll', { params }),
getPayslips: (params?: any) => api.get('/payslips', { params }),
getPayslip: (id: string) => api.get(`/payslips/${id}`),
downloadPdf: (id: string) =>
api.get(`/payslips/${id}/pdf`, { responseType: 'blob' }),
};
// Reimbursements
export const reimbursementsApi = {
create: (data: any, receipt?: File) => {
const formData = new FormData();
Object.keys(data).forEach((key) => formData.append(key, data[key]));
if (receipt) formData.append('receipt', receipt);
return api.post('/reimbursements', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
getAll: (params?: any) => api.get('/reimbursements', { params }),
approve: (id: string, comment?: string) =>
api.put(`/reimbursements/${id}/approve`, { comment }),
reject: (id: string, comment: string) =>
api.put(`/reimbursements/${id}/reject`, { comment }),
markPaid: (id: string) => api.put(`/reimbursements/${id}/mark-paid`),
};
// Announcements
export const announcementsApi = {
getAll: () => api.get('/announcements'),
create: (data: any) => api.post('/announcements', data),
update: (id: string, data: any) => api.put(`/announcements/${id}`, data),
delete: (id: string) => api.delete(`/announcements/${id}`),
};
// Tax
export const taxApi = {
submitDeclaration: (data: any) => api.post('/tax/declarations', data),
getDeclaration: (employeeId: string) => api.get(`/tax/declarations/${employeeId}`),
getTDSProjection: (employeeId: string) => api.get(`/tax/tds-projection/${employeeId}`),
};
// Reports
export const reportsApi = {
getHeadcount: () => api.get('/reports/headcount'),
getPayrollSummary: (month: number, year: number) =>
api.get('/reports/payroll-summary', { params: { month, year } }),
getLeaveUtilization: (year: number) =>
api.get('/reports/leave-utilization', { params: { year } }),
getAttendanceSummary: (month: number, year: number) =>
api.get('/reports/attendance-summary', { params: { month, year } }),
};
// Admin
export const adminApi = {
getAccounts: () => api.get('/admin/accounts'),
createAccount: (data: any) => api.post('/admin/accounts', data),
deactivateAccount: (id: string) => api.put(`/admin/accounts/${id}/deactivate`),
getAuditLogs: (params?: any) => api.get('/audit-logs', { params }),
getOrgSettings: () => api.get('/org/settings'),
updateOrgSettings: (data: any) => api.put('/org/settings', data),
};

Some files were not shown because too many files have changed in this diff Show More