deploy: hr-portal
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
uploads/
|
||||
+44
@@ -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"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+7780
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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's Attendance</p>
|
||||
<p className="text-xs text-indigo-600 mb-3">You haven'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user