diff --git a/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs b/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs index 2a84611..25af869 100644 --- a/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs +++ b/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs @@ -62,4 +62,14 @@ public sealed class DepartmentsController : ApiControllerBase await _departments.SetStatusAsync(departmentId, request.Status, ct); return NoContent(); } + + [HttpDelete("{departmentId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int departmentId, CancellationToken ct) + { + await _departments.DeleteAsync(departmentId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs b/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs index 6ebc1fe..ba2000b 100644 --- a/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs +++ b/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs @@ -61,4 +61,14 @@ public sealed class DesignationsController : ApiControllerBase await _designations.SetStatusAsync(designationId, request.Status, ct); return NoContent(); } + + [HttpDelete("{designationId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int designationId, CancellationToken ct) + { + await _designations.DeleteAsync(designationId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs b/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs index e80d878..e954b73 100644 --- a/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs +++ b/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs @@ -61,4 +61,14 @@ public sealed class EmploymentTypesController : ApiControllerBase await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct); return NoContent(); } + + [HttpDelete("{employmentTypeId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int employmentTypeId, CancellationToken ct) + { + await _employmentTypes.DeleteAsync(employmentTypeId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs b/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs index 6b4b557..8e6824e 100644 --- a/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs +++ b/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs @@ -61,4 +61,14 @@ public sealed class HrDocumentTypesController : ApiControllerBase await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct); return NoContent(); } + + [HttpDelete("{hrDocumentTypeId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int hrDocumentTypeId, CancellationToken ct) + { + await _types.DeleteAsync(hrDocumentTypeId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs b/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs index 9f28099..91aef6d 100644 --- a/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs +++ b/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs @@ -61,4 +61,14 @@ public sealed class LeaveTypesController : ApiControllerBase await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct); return NoContent(); } + + [HttpDelete("{leaveTypeId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int leaveTypeId, CancellationToken ct) + { + await _leaveTypes.DeleteAsync(leaveTypeId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs b/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs index d8108cd..a84322d 100644 --- a/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs +++ b/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs @@ -61,4 +61,14 @@ public sealed class SalaryComponentsController : ApiControllerBase await _components.SetStatusAsync(salaryComponentId, request.Status, ct); return NoContent(); } + + [HttpDelete("{salaryComponentId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int salaryComponentId, CancellationToken ct) + { + await _components.DeleteAsync(salaryComponentId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs b/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs index 88148d9..2b2dd2d 100644 --- a/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs +++ b/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs @@ -61,4 +61,14 @@ public sealed class WorkShiftsController : ApiControllerBase await _shifts.SetStatusAsync(workShiftId, request.Status, ct); return NoContent(); } + + [HttpDelete("{workShiftId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int workShiftId, CancellationToken ct) + { + await _shifts.DeleteAsync(workShiftId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Services/Hrm/DepartmentService.cs b/Backend/ERPCore/Services/Hrm/DepartmentService.cs index 5d79041..d99f867 100644 --- a/Backend/ERPCore/Services/Hrm/DepartmentService.cs +++ b/Backend/ERPCore/Services/Hrm/DepartmentService.cs @@ -127,6 +127,22 @@ public sealed class DepartmentService : IDepartmentService await _uow.SaveChangesAsync(ct); } + public async Task DeleteAsync(int departmentId, CancellationToken ct = default) + { + var dept = await _departments.GetByIdAsync(departmentId, ct) + ?? throw new NotFoundException($"Department {departmentId} was not found."); + + _departments.Remove(dept); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This department is referenced by other records (sub-departments or employees) and cannot be deleted.", 409); + } + } + /// Walks up from ; throws if it ever reaches . private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct) { diff --git a/Backend/ERPCore/Services/Hrm/DesignationService.cs b/Backend/ERPCore/Services/Hrm/DesignationService.cs index 9ca1fd5..424b561 100644 --- a/Backend/ERPCore/Services/Hrm/DesignationService.cs +++ b/Backend/ERPCore/Services/Hrm/DesignationService.cs @@ -101,5 +101,21 @@ public sealed class DesignationService : IDesignationService await _uow.SaveChangesAsync(ct); } + public async Task DeleteAsync(int designationId, CancellationToken ct = default) + { + var designation = await _designations.GetByIdAsync(designationId, ct) + ?? throw new NotFoundException($"Designation {designationId} was not found."); + + _designations.Remove(designation); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This designation is referenced by other records and cannot be deleted.", 409); + } + } + private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt); } diff --git a/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs b/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs index 6fbc80e..d2ab520 100644 --- a/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs +++ b/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs @@ -101,5 +101,21 @@ public sealed class EmploymentTypeService : IEmploymentTypeService await _uow.SaveChangesAsync(ct); } + public async Task DeleteAsync(int employmentTypeId, CancellationToken ct = default) + { + var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct) + ?? throw new NotFoundException($"Employment type {employmentTypeId} was not found."); + + _employmentTypes.Remove(et); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This employment type is referenced by other records and cannot be deleted.", 409); + } + } + private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt); } diff --git a/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs b/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs index 1f488d1..f5f7570 100644 --- a/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs +++ b/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs @@ -108,6 +108,22 @@ public sealed class HrDocumentTypeService : IHrDocumentTypeService await _uow.SaveChangesAsync(ct); } + public async Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(hrDocumentTypeId, ct) + ?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found."); + + _types.Remove(type); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This document type is referenced by other records and cannot be deleted.", 409); + } + } + private static HrDocumentTypeDto Map(HrDocumentType t) => new( t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt); } diff --git a/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs b/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs index 57e604f..d0819ce 100644 --- a/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs +++ b/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs @@ -43,6 +43,11 @@ public sealed class LeaveRequestService : ILeaveRequestService var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable(); if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId); if (status is not null) q = q.Where(r => r.Status == status); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%") || EF.Functions.ILike(r.Employee!.FullName, $"%{term}%")); + } var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(r => r.CreatedAt) diff --git a/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs b/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs index d1d63c5..9cda259 100644 --- a/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs +++ b/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs @@ -113,6 +113,22 @@ public sealed class LeaveTypeService : ILeaveTypeService await _uow.SaveChangesAsync(ct); } + public async Task DeleteAsync(int leaveTypeId, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(leaveTypeId, ct) + ?? throw new NotFoundException($"Leave type {leaveTypeId} was not found."); + + _types.Remove(type); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This leave type is referenced by other records and cannot be deleted.", 409); + } + } + private static LeaveTypeDto Map(LeaveType t) => new( t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear, t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt); diff --git a/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs b/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs index 85150a6..9e7af81 100644 --- a/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs +++ b/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs @@ -107,6 +107,22 @@ public sealed class SalaryComponentService : ISalaryComponentService await _uow.SaveChangesAsync(ct); } + public async Task DeleteAsync(int salaryComponentId, CancellationToken ct = default) + { + var component = await _components.GetByIdAsync(salaryComponentId, ct) + ?? throw new NotFoundException($"Salary component {salaryComponentId} was not found."); + + _components.Remove(component); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This salary component is referenced by other records and cannot be deleted.", 409); + } + } + private static SalaryComponentDto Map(SalaryComponent c) => new( c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt); } diff --git a/Backend/ERPCore/Services/Hrm/WorkShiftService.cs b/Backend/ERPCore/Services/Hrm/WorkShiftService.cs index 15d45b1..eabb31c 100644 --- a/Backend/ERPCore/Services/Hrm/WorkShiftService.cs +++ b/Backend/ERPCore/Services/Hrm/WorkShiftService.cs @@ -120,6 +120,22 @@ public sealed class WorkShiftService : IWorkShiftService await _uow.SaveChangesAsync(ct); } + public async Task DeleteAsync(int workShiftId, CancellationToken ct = default) + { + var shift = await _shifts.GetByIdAsync(workShiftId, ct) + ?? throw new NotFoundException($"Work shift {workShiftId} was not found."); + + _shifts.Remove(shift); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This work shift is referenced by other records and cannot be deleted.", 409); + } + } + private static WorkShiftDto Map(WorkShift w) => new( w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight, w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask, diff --git a/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs b/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs index 40227fb..87299b6 100644 --- a/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs +++ b/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs @@ -13,4 +13,5 @@ public interface IDepartmentService Task> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default); Task> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int departmentId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IDesignationService.cs b/Backend/ERPCore/Services/Interfaces/IDesignationService.cs index 78b2eb8..a1856e8 100644 --- a/Backend/ERPCore/Services/Interfaces/IDesignationService.cs +++ b/Backend/ERPCore/Services/Interfaces/IDesignationService.cs @@ -12,4 +12,5 @@ public interface IDesignationService Task> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default); Task> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int designationId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs b/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs index de766c3..44c33f0 100644 --- a/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs +++ b/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs @@ -12,4 +12,5 @@ public interface IEmploymentTypeService Task> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default); Task> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int employmentTypeId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs b/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs index a58dcb2..03bc0cf 100644 --- a/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs +++ b/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs @@ -13,4 +13,5 @@ public interface IHrDocumentTypeService Task> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default); Task> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs b/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs index a9bbccd..eca082f 100644 --- a/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs +++ b/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs @@ -12,4 +12,5 @@ public interface ILeaveTypeService Task> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default); Task> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int leaveTypeId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs b/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs index 97c6a0e..a2eb016 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs @@ -12,4 +12,5 @@ public interface ISalaryComponentService Task> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default); Task> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int salaryComponentId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs b/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs index 8534189..3338ec5 100644 --- a/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs +++ b/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs @@ -12,4 +12,5 @@ public interface IWorkShiftService Task> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default); Task> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int workShiftId, CancellationToken ct = default); } diff --git a/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx index 2d1d89e..083bd3a 100644 --- a/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import { useParams, useSearchParams } from "next/navigation" -import { Briefcase, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react" +import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react" import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees" import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters" @@ -25,13 +25,14 @@ import { } from "@/types/hrm" import { useAuth } from "@/components/auth/AuthProvider" -import { Button } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" @@ -60,8 +61,11 @@ export default function EmployeeDetailPage() { const [workShifts, setWorkShifts] = useState([]) const [branches, setBranches] = useState([]) const [saving, setSaving] = useState(false) + const [statusPending, setStatusPending] = useState(false) const [bankDetails, setBankDetails] = useState([]) + const [bankDraft, setBankDraft] = useState([]) + const [savingBank, setSavingBank] = useState(false) const [documents, setDocuments] = useState([]) const [documentTypes, setDocumentTypes] = useState([]) const [uploadTypeId, setUploadTypeId] = useState("") @@ -102,7 +106,11 @@ export default function EmployeeDetailPage() { }, [branchingEnabled]) useEffect(() => { - if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err))) + if (tab === "Bank Details") { + employeesApi.listBankDetails(employeeId) + .then((items) => { setBankDetails(items); setBankDraft(items.map((b) => ({ ...b }))) }) + .catch((err) => toast.error("Could not load bank details", errorMessage(err))) + } if (tab === "Documents") employeesApi.listDocuments(employeeId).then(setDocuments).catch((err) => toast.error("Could not load documents", errorMessage(err))) if (tab === "Salary & Loans") { employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures).catch((err) => toast.error("Could not load salary history", errorMessage(err))) @@ -111,6 +119,15 @@ export default function EmployeeDetailPage() { } }, [tab, employeeId]) + // The active structure has no `effectiveTo`; fall back to the most recent one if none is marked Active yet. + const currentStructure = salaryStructures.find((s) => s.status === "Active") ?? salaryStructures[0] + const componentTypeById = new Map(salaryComponents.map((c) => [c.salaryComponentId, c.componentType])) + const structureLinesTyped = currentStructure?.lines ?? [] + const earningLines = structureLinesTyped.filter((l) => componentTypeById.get(l.salaryComponentId) !== "Deduction") + const deductionLines = structureLinesTyped.filter((l) => componentTypeById.get(l.salaryComponentId) === "Deduction") + const grossPay = (currentStructure?.basicSalary ?? 0) + earningLines.reduce((sum, l) => sum + l.amount, 0) + const netPay = grossPay - deductionLines.reduce((sum, l) => sum + l.amount, 0) + function addStructureLine() { setStructureLines((prev) => [...prev, { salaryComponentId: "", amount: 0 }]) } @@ -203,9 +220,60 @@ export default function EmployeeDetailPage() { function handleCancelEdit() { setIsEditing(false) + setBankDraft(bankDetails.map((b) => ({ ...b }))) load() } + // "Inactive" here maps to Suspended — a reversible off-switch, distinct from the + // one-way separation statuses (Resigned/Terminated/Retired) which need their own flow. + async function handleToggleStatus() { + if (!employee) return + const nextStatus = employee.status === "Active" ? "Suspended" : "Active" + setStatusPending(true) + try { + await employeesApi.updateStatus(employeeId, nextStatus) + setEmployee({ ...employee, status: nextStatus }) + toast.success(nextStatus === "Active" ? "Employee activated" : "Employee deactivated") + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setStatusPending(false) + } + } + + const BLANK_BANK_DETAIL: EmployeeBankDetail = { + employeeBankDetailId: null, bankName: "", branchName: "", accountNumber: "", accountHolderName: "", swiftCode: null, isPrimary: true, status: "Active", + } + + function updateBankRow(index: number, patch: Partial) { + setBankDraft((prev) => { + const base = prev[index] ?? BLANK_BANK_DETAIL + const next = [...prev] + next[index] = { ...base, ...patch } + return next + }) + } + + async function handleSaveBank() { + const b = bankDraft[0] + if (!b || !b.bankName.trim() || !b.branchName.trim() || !b.accountNumber.trim() || !b.accountHolderName.trim()) { + toast.error("Bank name, branch, account number, and account holder name are required") + return + } + + setSavingBank(true) + try { + const saved = await employeesApi.replaceBankDetails(employeeId, bankDraft) + setBankDetails(saved) + setBankDraft(saved.map((b) => ({ ...b }))) + toast.success("Bank details updated") + } catch (err) { + toast.error("Could not save bank details", errorMessage(err)) + } finally { + setSavingBank(false) + } + } + async function handleUpload(file: File) { if (!uploadTypeId) { toast.error("Select a document type first"); return } try { @@ -238,6 +306,20 @@ export default function EmployeeDetailPage() { )}

+ + {(employee.status === "Active" || employee.status === "Suspended") && ( +
+ + {employee.status === "Active" ? "Active" : "Inactive"} + + +
+ )}
@@ -267,8 +349,8 @@ export default function EmployeeDetailPage() {
Full name setEmployee({ ...employee, fullName: e.target.value })} /> - Email setEmployee({ ...employee, email: e.target.value })} /> NIC setEmployee({ ...employee, nic: e.target.value })} /> + Email setEmployee({ ...employee, email: e.target.value })} /> Personal mobile setEmployee({ ...employee, personalMobile: e.target.value })} />
@@ -285,7 +367,16 @@ export default function EmployeeDetailPage() {
-
+
+ {branchingEnabled && ( + + Branch + + + )} Department - {branchingEnabled && ( - - Branch - - - )}
@@ -338,7 +420,7 @@ export default function EmployeeDetailPage() {
-
+
EPF number setEmployee({ ...employee, epfNumber: e.target.value })} /> ETF number setEmployee({ ...employee, etfNumber: e.target.value })} />
@@ -358,48 +440,68 @@ export default function EmployeeDetailPage() { )} {tab === "Bank Details" && ( -
-
- - - - Bank - Branch - Account - Primary - - - - {bankDetails.map((b, i) => ( - - {b.bankName} - {b.branchName} - {b.accountNumber} - {b.isPrimary ? "Yes" : "—"} - - ))} - {bankDetails.length === 0 && ( - No bank details on file. - )} - -
-
+
+
+
+ Bank name updateBankRow(0, { bankName: e.target.value })} /> + Branch name updateBankRow(0, { branchName: e.target.value })} /> + Account number updateBankRow(0, { accountNumber: e.target.value })} /> + Account holder name updateBankRow(0, { accountHolderName: e.target.value })} /> + SWIFT code (optional) updateBankRow(0, { swiftCode: e.target.value || null })} /> +
+
+ + {isEditing && ( +
+ + +
+ )}
)} {tab === "Documents" && (
-
- - -
+
+
+
+ +
+
+

Upload a document

+

NIC, contracts, certificates — PDF, image, or Word file.

+
+
+
+ + Document type + + + +
+
@@ -408,19 +510,40 @@ export default function EmployeeDetailPage() { FileTypeUploaded - Status - Download + Status + Actions {documents.map((d) => ( - {d.originalFileName} - {d.hrDocumentTypeName ?? "—"} - {new Date(d.uploadedAt).toLocaleDateString()} - {d.status} + {d.originalFileName} + {d.hrDocumentTypeName ?? "—"} + {new Date(d.uploadedAt).toLocaleDateString()} - Download + + {d.status} + + + +
+ + + +
))} @@ -474,6 +597,53 @@ export default function EmployeeDetailPage() { + {currentStructure && ( +
+
+

Current breakdown

+ Effective {new Date(currentStructure.effectiveFrom).toLocaleDateString()} +
+ +
+
+ Basic salary + {money(currentStructure.basicSalary)} +
+ {earningLines.map((l) => ( +
+ {l.salaryComponentName ?? "Allowance"} + +{money(l.amount)} +
+ ))} +
+ +
+ Gross pay + {money(grossPay)} +
+ + {deductionLines.length > 0 && ( + <> +
+ {deductionLines.map((l) => ( +
+ {l.salaryComponentName ?? "Deduction"} + −{money(l.amount)} +
+ ))} +
+
+ Net pay + {money(netPay)} +
+ + )} +

+ OT, Late/No-Pay, loan installments, EPF/ETF, and tax are computed at payroll time and aren't part of this structure. +

+
+ )} +
diff --git a/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx b/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx index e2d9a05..5c538b3 100644 --- a/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useState } from "react" -import { Plus } from "lucide-react" +import { CheckCircle2, ChevronLeft, ChevronRight, Plus, Search, X, XCircle } from "lucide-react" import { employeesApi } from "@/lib/api/employees" import { leaveTypesApi } from "@/lib/api/hrm-masters" @@ -14,14 +14,17 @@ import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" -import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Textarea } from "@/components/ui/textarea" import { toast } from "@/components/ui/toast" -const PAGE_SIZE = 10 +const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] +const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"] +const ALL_STATUS = "__all__" const STATUS_STYLE: Record = { Draft: "bg-muted text-muted-foreground", @@ -36,6 +39,13 @@ export default function LeaveRequestsPage() { const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(5) + const [statusFilter, setStatusFilter] = useState(ALL_STATUS) + const [search, setSearch] = useState("") + const [debouncedSearch, setDebouncedSearch] = useState("") + const [actionPendingId, setActionPendingId] = useState(null) + + const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch const [employees, setEmployees] = useState([]) const [leaveTypes, setLeaveTypes] = useState([]) @@ -48,13 +58,32 @@ export default function LeaveRequestsPage() { const [reason, setReason] = useState("") const [submitting, setSubmitting] = useState(false) + const [rejectTarget, setRejectTarget] = useState(null) + const [rejectReason, setRejectReason] = useState("") + const [rejectError, setRejectError] = useState(null) + const [rejecting, setRejecting] = useState(false) + function load() { leaveRequestsApi - .list({ page, pageSize: PAGE_SIZE }) + .list({ + page, + pageSize, + q: debouncedSearch || undefined, + status: statusFilter === ALL_STATUS ? undefined : (statusFilter as LeaveRequestStatus), + }) .then((res) => { setRequests(res.items); setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) } - useEffect(load, [page]) + + useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const t = setTimeout(() => { + setPage(1) + setDebouncedSearch(search.trim()) + }, 300) + return () => clearTimeout(t) + }, [search]) useEffect(() => { employeesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmployees(r.items)).catch(() => setEmployees([])) @@ -79,24 +108,38 @@ export default function LeaveRequestsPage() { } async function approve(id: number) { + setActionPendingId(id) try { await leaveRequestsApi.approve(id) toast.success("Leave approved") load() } catch (err) { toast.error("Could not approve", errorMessage(err)) + } finally { + setActionPendingId(null) } } - async function reject(id: number) { - const reasonText = window.prompt("Reason for rejection:") - if (!reasonText) return + function openReject(request: LeaveRequest) { + setRejectTarget(request) + setRejectReason("") + setRejectError(null) + } + + async function handleReject() { + if (!rejectTarget) return + if (!rejectReason.trim()) { setRejectError("Reason is required"); return } + + setRejecting(true) try { - await leaveRequestsApi.reject(id, reasonText) + await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim()) toast.success("Leave rejected") + setRejectTarget(null) load() } catch (err) { toast.error("Could not reject", errorMessage(err)) + } finally { + setRejecting(false) } } @@ -104,7 +147,14 @@ export default function LeaveRequestsPage() {
-

Leave

+
+

Leave

+ {pagination && ( + + {pagination.totalItems} + + )} +

Approved leave feeds Attendance's OnLeave status and Payroll's No-Pay calculation.

@@ -143,53 +193,228 @@ export default function LeaveRequestsPage() {
+ !v && setRejectTarget(null)}> + + + Reject leave request + + {rejectTarget && <>Rejecting {rejectTarget.docNo} for {rejectTarget.employeeName ?? "this employee"}. The employee will see this reason.} + + + + + Reason for rejection +