Users and employees arrangements #56
@@ -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<IActionResult> Delete(int departmentId, CancellationToken ct)
|
||||
{
|
||||
await _departments.DeleteAsync(departmentId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> Delete(int designationId, CancellationToken ct)
|
||||
{
|
||||
await _designations.DeleteAsync(designationId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
|
||||
{
|
||||
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
|
||||
{
|
||||
await _types.DeleteAsync(hrDocumentTypeId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
|
||||
{
|
||||
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
|
||||
{
|
||||
await _components.DeleteAsync(salaryComponentId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> Delete(int workShiftId, CancellationToken ct)
|
||||
{
|
||||
await _shifts.DeleteAsync(workShiftId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Walks up from <paramref name="newParentId"/>; throws if it ever reaches <paramref name="departmentId"/>.</summary>
|
||||
private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,4 +13,5 @@ public interface IDepartmentService
|
||||
Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<DepartmentDto>> 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);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ public interface IDesignationService
|
||||
Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<DesignationDto>> 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);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ public interface IEmploymentTypeService
|
||||
Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<EmploymentTypeDto>> 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);
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ public interface IHrDocumentTypeService
|
||||
Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<HrDocumentTypeDto>> 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);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ public interface ILeaveTypeService
|
||||
Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<LeaveTypeDto>> 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);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ public interface ISalaryComponentService
|
||||
Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<SalaryComponentDto>> 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);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ public interface IWorkShiftService
|
||||
Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<WorkShiftDto>> 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);
|
||||
}
|
||||
|
||||
@@ -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<WorkShift[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [statusPending, setStatusPending] = useState(false)
|
||||
|
||||
const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([])
|
||||
const [bankDraft, setBankDraft] = useState<EmployeeBankDetail[]>([])
|
||||
const [savingBank, setSavingBank] = useState(false)
|
||||
const [documents, setDocuments] = useState<EmployeeDocument[]>([])
|
||||
const [documentTypes, setDocumentTypes] = useState<HrDocumentType[]>([])
|
||||
const [uploadTypeId, setUploadTypeId] = useState<string>("")
|
||||
@@ -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<EmployeeBankDetail>) {
|
||||
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() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(employee.status === "Active" || employee.status === "Suspended") && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-sm font-medium", employee.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
|
||||
{employee.status === "Active" ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={employee.status === "Active"}
|
||||
onCheckedChange={handleToggleStatus}
|
||||
disabled={statusPending}
|
||||
aria-label={employee.status === "Active" ? "Deactivate employee" : "Activate employee"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-1 overflow-x-auto rounded-xl bg-muted p-1">
|
||||
@@ -267,8 +349,8 @@ export default function EmployeeDetailPage() {
|
||||
<FieldGroup>
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field><FieldLabel>Full name</FieldLabel><Input disabled={!isEditing} value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Email</FieldLabel><Input disabled={!isEditing} type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>NIC</FieldLabel><Input disabled={!isEditing} value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Email</FieldLabel><Input disabled={!isEditing} type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Personal mobile</FieldLabel><Input disabled={!isEditing} value={employee.personalMobile ?? ""} onChange={(e) => setEmployee({ ...employee, personalMobile: e.target.value })} /></Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
@@ -285,7 +367,16 @@ export default function EmployeeDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<FieldGroup>
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{branchingEnabled && (
|
||||
<Field>
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
|
||||
<SelectTrigger disabled={!isEditing || !isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel>Department</FieldLabel>
|
||||
<Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}>
|
||||
@@ -314,15 +405,6 @@ export default function EmployeeDetailPage() {
|
||||
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{branchingEnabled && (
|
||||
<Field>
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
|
||||
<SelectTrigger disabled={!isEditing || !isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
@@ -338,7 +420,7 @@ export default function EmployeeDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<FieldGroup>
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 sm:max-w-md">
|
||||
<Field><FieldLabel>EPF number</FieldLabel><Input disabled={!isEditing} value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>ETF number</FieldLabel><Input disabled={!isEditing} value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
|
||||
</div>
|
||||
@@ -358,48 +440,68 @@ export default function EmployeeDetailPage() {
|
||||
)}
|
||||
|
||||
{tab === "Bank Details" && (
|
||||
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Bank</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Branch</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Account</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Primary</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bankDetails.map((b, i) => (
|
||||
<TableRow key={b.employeeBankDetailId ?? i} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5">{b.bankName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{b.branchName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{b.accountNumber}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{b.isPrimary ? "Yes" : "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{bankDetails.length === 0 && (
|
||||
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No bank details on file.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Bank name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.bankName ?? ""} onChange={(e) => updateBankRow(0, { bankName: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Branch name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.branchName ?? ""} onChange={(e) => updateBankRow(0, { branchName: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Account number</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.accountNumber ?? ""} onChange={(e) => updateBankRow(0, { accountNumber: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Account holder name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.accountHolderName ?? ""} onChange={(e) => updateBankRow(0, { accountHolderName: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>SWIFT code (optional)</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.swiftCode ?? ""} onChange={(e) => updateBankRow(0, { swiftCode: e.target.value || null })} /></Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}>
|
||||
<X className="size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "Documents" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:flex-row sm:items-center">
|
||||
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
|
||||
<SelectTrigger className="w-full sm:w-56"><SelectValue placeholder="Document type" /></SelectTrigger>
|
||||
<SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<label className={cn("inline-flex w-fit cursor-pointer items-center gap-2 rounded-lg border border-black px-4 py-2 text-sm font-medium hover:bg-muted")}>
|
||||
<Upload className="size-4" />
|
||||
Upload
|
||||
<input type="file" className="hidden" accept=".pdf,.jpg,.jpeg,.png,.docx" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = "" }} />
|
||||
</label>
|
||||
</div>
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Upload className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Upload a document</h2>
|
||||
<p className="text-sm text-muted-foreground">NIC, contracts, certificates — PDF, image, or Word file.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<Field className="w-full sm:max-w-64">
|
||||
<FieldLabel htmlFor="doc-upload-type">Document type</FieldLabel>
|
||||
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
|
||||
<SelectTrigger id="doc-upload-type"><SelectValue placeholder="Select a type" /></SelectTrigger>
|
||||
<SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<label
|
||||
className={cn(
|
||||
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium",
|
||||
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
|
||||
)}
|
||||
title={uploadTypeId ? undefined : "Select a document type first"}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Choose file to upload
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
disabled={!uploadTypeId}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.docx"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = "" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
@@ -408,19 +510,40 @@ export default function EmployeeDetailPage() {
|
||||
<TableHead className="h-11 px-4 text-sm">File</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Download</TableHead>
|
||||
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{documents.map((d) => (
|
||||
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5">{d.originalFileName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{d.status}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<a href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">Download</a>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Download ${d.originalFileName}`}
|
||||
title={`Download ${d.originalFileName}`}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -474,6 +597,53 @@ export default function EmployeeDetailPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
{currentStructure && (
|
||||
<div className="rounded-xl border p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">Current breakdown</h3>
|
||||
<span className="text-xs text-muted-foreground">Effective {new Date(currentStructure.effectiveFrom).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-1.5 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<span className="text-muted-foreground">Basic salary</span>
|
||||
<span className="text-right font-medium text-foreground">{money(currentStructure.basicSalary)}</span>
|
||||
</div>
|
||||
{earningLines.map((l) => (
|
||||
<div key={l.salaryComponentId} className="grid grid-cols-2 gap-2">
|
||||
<span className="text-muted-foreground">{l.salaryComponentName ?? "Allowance"}</span>
|
||||
<span className="text-right font-medium text-foreground">+{money(l.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3 text-sm">
|
||||
<span className="font-semibold text-foreground">Gross pay</span>
|
||||
<span className="text-right font-semibold text-foreground">{money(grossPay)}</span>
|
||||
</div>
|
||||
|
||||
{deductionLines.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex flex-col gap-1.5 text-sm">
|
||||
{deductionLines.map((l) => (
|
||||
<div key={l.salaryComponentId} className="grid grid-cols-2 gap-2">
|
||||
<span className="text-muted-foreground">{l.salaryComponentName ?? "Deduction"}</span>
|
||||
<span className="text-right font-medium text-destructive">−{money(l.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3 text-sm">
|
||||
<span className="font-semibold text-foreground">Net pay</span>
|
||||
<span className="text-right font-semibold text-foreground">{money(netPay)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
OT, Late/No-Pay, loan installments, EPF/ETF, and tax are computed at payroll time and aren't part of this structure.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
|
||||
@@ -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<LeaveRequestStatus, string> = {
|
||||
Draft: "bg-muted text-muted-foreground",
|
||||
@@ -36,6 +39,13 @@ export default function LeaveRequestsPage() {
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(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<number | null>(null)
|
||||
|
||||
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
|
||||
|
||||
const [employees, setEmployees] = useState<EmployeeListItem[]>([])
|
||||
const [leaveTypes, setLeaveTypes] = useState<LeaveType[]>([])
|
||||
@@ -48,13 +58,32 @@ export default function LeaveRequestsPage() {
|
||||
const [reason, setReason] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [rejectTarget, setRejectTarget] = useState<LeaveRequest | null>(null)
|
||||
const [rejectReason, setRejectReason] = useState("")
|
||||
const [rejectError, setRejectError] = useState<string | null>(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() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Leave</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Leave</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Approved leave feeds Attendance's OnLeave status and Payroll's No-Pay calculation.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -143,53 +193,228 @@ export default function LeaveRequestsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!rejectTarget} onOpenChange={(v) => !v && setRejectTarget(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Reject leave request</DialogTitle>
|
||||
<DialogDescription>
|
||||
{rejectTarget && <>Rejecting {rejectTarget.docNo} for {rejectTarget.employeeName ?? "this employee"}. The employee will see this reason.</>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!rejectError}>
|
||||
<FieldLabel htmlFor="lv-reject-reason">Reason for rejection</FieldLabel>
|
||||
<Textarea
|
||||
id="lv-reject-reason"
|
||||
value={rejectReason}
|
||||
onChange={(e) => { setRejectReason(e.target.value); setRejectError(null) }}
|
||||
aria-invalid={!!rejectError}
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<FieldError errors={[rejectError ? { message: rejectError } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setRejectTarget(null)} disabled={rejecting}>Cancel</Button>
|
||||
<Button variant="destructive" className="w-full sm:w-auto sm:min-w-32" onClick={handleReject} disabled={rejecting}>
|
||||
{rejecting ? "Rejecting…" : "Reject"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by doc no or employee…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search leave requests"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { setPage(1); setStatusFilter(ALL_STATUS); setSearch("") }}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-1.5 sm:ml-auto sm:max-w-52">
|
||||
<label htmlFor="lv-filter-status" className="text-xs font-medium text-muted-foreground">Status</label>
|
||||
<Select value={statusFilter} onValueChange={(v) => { setPage(1); setStatusFilter(v ?? ALL_STATUS) }}>
|
||||
<SelectTrigger id="lv-filter-status" aria-label="Filter by status"><SelectValue placeholder="All statuses" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_STATUS}>All statuses</SelectItem>
|
||||
{STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && requests === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
||||
|
||||
{!error && requests === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-auto h-7 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && requests !== null && requests.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No leave requests yet.</p></div>
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">No leave requests yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && requests !== null && requests.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Dates</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Days</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.map((r) => (
|
||||
<TableRow key={r.leaveRequestId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.employeeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.leaveTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.daysCount}</TableCell>
|
||||
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>{r.status}</Badge></TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{r.status === "Submitted" && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => approve(r.leaveRequestId)}>Approve</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => reject(r.leaveRequestId)}>Reject</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.map((r) => (
|
||||
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
{r.status === "Submitted" ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-success hover:bg-success/10"
|
||||
aria-label={`Approve ${r.docNo}`}
|
||||
title={`Approve ${r.docNo}`}
|
||||
disabled={actionPendingId === r.leaveRequestId}
|
||||
onClick={() => approve(r.leaveRequestId)}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Reject ${r.docNo}`}
|
||||
title={`Reject ${r.docNo}`}
|
||||
disabled={actionPendingId === r.leaveRequestId}
|
||||
onClick={() => openReject(r)}
|
||||
>
|
||||
<XCircle className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="flex justify-center text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ArrowLeft, Download, Printer } from "lucide-react"
|
||||
|
||||
import { payrollRunsApi } from "@/lib/api/payroll"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PayrollLineDetail } from "@/types/hrm"
|
||||
import { buildSimplePdf, downloadPdf, PdfLine } from "@/lib/simple-pdf"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PayrollLineDetail, PayrollRun } from "@/types/hrm"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
function money(n: number): string {
|
||||
@@ -18,43 +23,119 @@ export default function PayrollLineDetailPage() {
|
||||
const runId = Number(params.id)
|
||||
const lineId = Number(params.lineId)
|
||||
|
||||
const [run, setRun] = useState<PayrollRun | null>(null)
|
||||
const [detail, setDetail] = useState<PayrollLineDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
payrollRunsApi.getLine(runId, lineId).then(setDetail).catch((err) => setError(errorMessage(err)))
|
||||
Promise.all([payrollRunsApi.get(runId), payrollRunsApi.getLine(runId, lineId)])
|
||||
.then(([r, d]) => { setRun(r); setDetail(d) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [runId, lineId])
|
||||
|
||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
if (!detail) return <div className="flex flex-col gap-3">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
|
||||
if (!detail || !run) return <div className="flex flex-col gap-3">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
|
||||
|
||||
const { line, components } = detail
|
||||
const earnings = components.filter((c) => c.componentCategory === "Earning")
|
||||
const deductions = components.filter((c) => c.componentCategory === "Deduction")
|
||||
const employerContributions = components.filter((c) => c.componentCategory === "EmployerContribution")
|
||||
const period = `${run.periodMonth.toString().padStart(2, "0")}/${run.periodYear}`
|
||||
|
||||
function handleDownload() {
|
||||
if (!run) return
|
||||
const left = 56
|
||||
const right = 556
|
||||
const muted: [number, number, number] = [0.45, 0.45, 0.45]
|
||||
const destructive: [number, number, number] = [0.7, 0.15, 0.15]
|
||||
const lines: PdfLine[] = []
|
||||
let y = 740
|
||||
|
||||
lines.push({ text: "Salary Breakdown", x: left, y, size: 18, bold: true })
|
||||
y -= 22
|
||||
lines.push({ text: `${line.employeeName ?? ""} (${line.employeeCode ?? ""})`, x: left, y, size: 11, color: muted })
|
||||
lines.push({ text: `Period ${period}`, x: right - 90, y, size: 11, color: muted })
|
||||
y -= 14
|
||||
lines.push({ text: run.docNo, x: right - 90, y, size: 9, color: muted })
|
||||
y -= 28
|
||||
|
||||
const row = (label: string, amount: number, opts: { bold?: boolean; size?: number; color?: [number, number, number] } = {}) => {
|
||||
lines.push({ text: label, x: left, y, size: opts.size ?? 11, bold: opts.bold, color: opts.color })
|
||||
lines.push({ text: money(amount), x: right - money(amount).length * 6, y, size: opts.size ?? 11, bold: opts.bold, color: opts.color })
|
||||
y -= 18
|
||||
}
|
||||
|
||||
earnings.forEach((c) => row(c.label, c.amount))
|
||||
row("Gross Salary", line.grossSalary, { bold: true })
|
||||
y -= 10
|
||||
lines.push({ text: "Deductions", x: left, y, size: 10, bold: true, color: muted })
|
||||
y -= 18
|
||||
deductions.forEach((c) => row(c.label, c.amount, { color: destructive }))
|
||||
row("Net Salary", line.netSalary, { bold: true, size: 13 })
|
||||
|
||||
if (employerContributions.length > 0) {
|
||||
y -= 10
|
||||
lines.push({ text: "Employer Contributions (informational — not deducted)", x: left, y, size: 9, bold: true, color: muted })
|
||||
y -= 16
|
||||
employerContributions.forEach((c) => row(c.label, c.amount, { size: 10, color: muted }))
|
||||
}
|
||||
|
||||
y -= 20
|
||||
lines.push({
|
||||
text: `Present ${line.presentDays} Absent ${line.absentDays} Leave ${line.leaveDays} OT ${line.otMinutesTotal} min Late ${line.lateMinutesTotal} min`,
|
||||
x: left, y, size: 9, color: muted,
|
||||
})
|
||||
|
||||
const blob = buildSimplePdf(lines)
|
||||
downloadPdf(blob, `salary-breakdown-${line.employeeCode ?? line.employeeId}-${period.replace("/", "-")}.pdf`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-lg flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
|
||||
<p className="text-base text-muted-foreground">{line.employeeName} ({line.employeeCode})</p>
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 print:hidden sm:flex-row sm:items-center sm:justify-between">
|
||||
<Link href={`/dashboard/hrm/payroll/${runId}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "w-fit")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to payroll run
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="size-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<table className="w-full text-base">
|
||||
<div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
|
||||
<p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Period</p>
|
||||
<p className="font-semibold text-foreground">{period}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="mt-5 w-full text-base">
|
||||
<tbody>
|
||||
{earnings.map((c, i) => (
|
||||
<tr key={i}><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
|
||||
<tr key={i}><td className="py-1.5 text-muted-foreground">{c.label}</td><td className="py-1.5 text-right font-medium text-foreground">{money(c.amount)}</td></tr>
|
||||
))}
|
||||
<tr className="border-t font-semibold"><td className="py-2">Gross Salary</td><td className="py-2 text-right">{money(line.grossSalary)}</td></tr>
|
||||
<tr><td className="pt-4 text-sm font-semibold text-muted-foreground" colSpan={2}>Deductions</td></tr>
|
||||
<tr className="border-t border-border font-semibold text-foreground"><td className="py-2.5">Gross Salary</td><td className="py-2.5 text-right">{money(line.grossSalary)}</td></tr>
|
||||
<tr><td className="pt-5 pb-1 text-sm font-semibold text-muted-foreground" colSpan={2}>Deductions</td></tr>
|
||||
{deductions.map((c, i) => (
|
||||
<tr key={i}><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
|
||||
<tr key={i}><td className="py-1.5 text-muted-foreground">{c.label}</td><td className="py-1.5 text-right font-medium text-destructive">−{money(c.amount)}</td></tr>
|
||||
))}
|
||||
<tr className="border-t font-bold"><td className="py-2">Net Salary</td><td className="py-2 text-right">{money(line.netSalary)}</td></tr>
|
||||
<tr className="border-t border-border text-lg font-bold text-foreground"><td className="py-3">Net Salary</td><td className="py-3 text-right">{money(line.netSalary)}</td></tr>
|
||||
{employerContributions.length > 0 && (
|
||||
<>
|
||||
<tr><td className="pt-4 text-sm font-semibold text-muted-foreground" colSpan={2}>Employer Contributions (informational — not deducted)</td></tr>
|
||||
<tr><td className="pt-5 pb-1 text-sm font-semibold text-muted-foreground" colSpan={2}>Employer Contributions (informational — not deducted)</td></tr>
|
||||
{employerContributions.map((c, i) => (
|
||||
<tr key={i} className="text-muted-foreground"><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
|
||||
))}
|
||||
@@ -64,7 +145,7 @@ export default function PayrollLineDetailPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-3 rounded-xl border border-dashed p-4 text-sm text-muted-foreground sm:grid-cols-5 print:break-inside-avoid">
|
||||
<div>Present days: {line.presentDays}</div>
|
||||
<div>Absent days: {line.absentDays}</div>
|
||||
<div>Leave days: {line.leaveDays}</div>
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
|
||||
|
||||
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PayrollLine, PayrollRun, Payslip } from "@/types/hrm"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
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"
|
||||
@@ -18,6 +19,8 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
function money(n: number): string {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
@@ -33,6 +36,9 @@ export default function PayrollRunDetailPage() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [unlockOpen, setUnlockOpen] = useState(false)
|
||||
const [unlockReason, setUnlockReason] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
|
||||
function load() {
|
||||
Promise.all([payrollRunsApi.get(runId), payrollRunsApi.listLines(runId)])
|
||||
@@ -86,6 +92,15 @@ export default function PayrollRunDetailPage() {
|
||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
if (!run || !lines) return <div className="flex flex-col gap-3">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
||||
|
||||
const filteredLines = lines.filter((l) => {
|
||||
const term = search.trim().toLowerCase()
|
||||
if (!term) return true
|
||||
return (l.employeeName ?? "").toLowerCase().includes(term) || (l.employeeCode ?? "").toLowerCase().includes(term)
|
||||
})
|
||||
const totalPages = Math.max(1, Math.ceil(filteredLines.length / pageSize))
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
const pagedLines = filteredLines.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -128,43 +143,150 @@ export default function PayrollRunDetailPage() {
|
||||
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div>
|
||||
</div>
|
||||
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Basic</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Allowances</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Deductions</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Net Salary</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((l) => {
|
||||
const totalDeductions = l.grossSalary - l.netSalary
|
||||
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
||||
return (
|
||||
<TableRow key={l.payrollLineId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{l.employeeName} <span className="text-muted-foreground">({l.employeeCode})</span></TableCell>
|
||||
<TableCell className="px-3 py-3.5">{money(l.basicSalary)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{money(l.overtimeAmount)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{money(l.totalAllowances)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{money(totalDeductions)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-semibold">{money(l.netSalary)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`} className="text-primary underline underline-offset-2">Breakdown</Link>
|
||||
{payslip && (
|
||||
<a href={payslipViewUrl(payslip.payslipId)} target="_blank" rel="noreferrer" className={cn("text-primary underline underline-offset-2")}>Payslip</a>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 className="text-lg font-semibold text-foreground">Payroll lines</h2>
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">{lines.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search by name or employee code…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search payroll lines"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearch(""); setPage(1) }}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredLines.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{search ? <>No lines match "{search}".</> : "No payroll lines yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
|
||||
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedLines.map((l) => {
|
||||
const totalDeductions = l.grossSalary - l.netSalary
|
||||
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
||||
return (
|
||||
<TableRow key={l.payrollLineId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-3.5 font-medium text-foreground">
|
||||
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View breakdown for ${l.employeeName}`}
|
||||
title="View breakdown"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{payslip && (
|
||||
<a
|
||||
href={payslipViewUrl(payslip.payslipId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View payslip for ${l.employeeName}`}
|
||||
title="View payslip"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLines.length > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "}
|
||||
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Eye, Plus } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react"
|
||||
|
||||
import { payrollRunsApi } from "@/lib/api/payroll"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -15,11 +15,14 @@ import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, 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 { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
|
||||
const ALL_STATUS = "__all__"
|
||||
|
||||
const STATUS_STYLE: Record<PayrollRunStatus, string> = {
|
||||
Draft: "bg-muted text-muted-foreground",
|
||||
@@ -32,6 +35,8 @@ export default function PayrollRunsPage() {
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const now = new Date()
|
||||
@@ -41,11 +46,12 @@ export default function PayrollRunsPage() {
|
||||
|
||||
function load() {
|
||||
payrollRunsApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
|
||||
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
useEffect(load, [page])
|
||||
|
||||
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function handleGenerate() {
|
||||
setSubmitting(true)
|
||||
@@ -65,7 +71,14 @@ export default function PayrollRunsPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Payroll</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Payroll</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Generate → Review → Approve → Lock → Payslips.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -89,48 +102,140 @@ export default function PayrollRunsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<Select value={statusFilter} onValueChange={(v) => { setPage(1); setStatusFilter(v ?? ALL_STATUS) }}>
|
||||
<SelectTrigger aria-label="Filter by status"><SelectValue placeholder="All statuses" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_STATUS}>All statuses</SelectItem>
|
||||
{STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && runs === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
||||
|
||||
{!error && runs === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-auto h-7 w-7" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && runs !== null && runs.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No payroll runs yet.</p></div>
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">No payroll runs yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && runs !== null && runs.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Employees</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Gross</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Net</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((r) => (
|
||||
<TableRow key={r.payrollRunId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.employeeCount}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>{r.status}</Badge></TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/hrm/payroll/${r.payrollRunId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((r) => (
|
||||
<TableRow key={r.payrollRunId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${r.docNo}`}
|
||||
title={`View ${r.docNo}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Branch, Department } from "@/types/hrm"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -19,7 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
const NONE = "__none__"
|
||||
|
||||
export default function DepartmentsPage() {
|
||||
@@ -28,6 +29,10 @@ export default function DepartmentsPage() {
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -37,9 +42,18 @@ export default function DepartmentsPage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [editItem, setEditItem] = useState<Department | null>(null)
|
||||
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editParentDepartmentId, setEditParentDepartmentId] = useState(NONE)
|
||||
const [editBranchId, setEditBranchId] = useState(NONE)
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
function load() {
|
||||
departmentsApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => {
|
||||
setItems(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -47,7 +61,16 @@ export default function DepartmentsPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page])
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((res) => setBranches(res.items)).catch(() => setBranches([]))
|
||||
}, [])
|
||||
@@ -81,12 +104,71 @@ export default function DepartmentsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(item: Department) {
|
||||
setEditItem(item)
|
||||
setEditName(item.name)
|
||||
setEditParentDepartmentId(item.parentDepartmentId ? String(item.parentDepartmentId) : NONE)
|
||||
setEditBranchId(item.branchId ? String(item.branchId) : NONE)
|
||||
setEditErrors({})
|
||||
setEditEtag(null)
|
||||
setEditLoading(true)
|
||||
try {
|
||||
const res = await departmentsApi.get(item.departmentId)
|
||||
setEditEtag(res.etag)
|
||||
} catch (err) {
|
||||
toast.error("Could not load department", errorMessage(err))
|
||||
setEditItem(null)
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editItem || !editEtag) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
await departmentsApi.update(editItem.departmentId, {
|
||||
name: editName.trim(),
|
||||
parentDepartmentId: editParentDepartmentId === NONE ? null : Number(editParentDepartmentId),
|
||||
branchId: editBranchId === NONE ? null : Number(editBranchId),
|
||||
}, editEtag)
|
||||
toast.success("Department updated")
|
||||
setEditItem(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not save", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: Department) {
|
||||
setActionPendingId(item.departmentId)
|
||||
try {
|
||||
await departmentsApi.updateStatus(item.departmentId, item.status === "Active" ? "Inactive" : "Active")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: Department) {
|
||||
setActionPendingId(item.departmentId)
|
||||
try {
|
||||
await departmentsApi.remove(item.departmentId)
|
||||
toast.success("Department deleted")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete department", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +176,14 @@ export default function DepartmentsPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Departments</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Departments</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Org structure — unlimited nesting, optionally scoped to a branch.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -148,48 +237,268 @@ export default function DepartmentsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit Department</DialogTitle>
|
||||
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-edit-code">Code</FieldLabel>
|
||||
<Input id="d-edit-code" value={editItem?.code ?? ""} disabled />
|
||||
</Field>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="d-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="d-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-edit-parent">Parent department (optional)</FieldLabel>
|
||||
<Select value={editParentDepartmentId} onValueChange={(v) => setEditParentDepartmentId(v ?? NONE)} disabled={editLoading}>
|
||||
<SelectTrigger id="d-edit-parent"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{(items ?? []).filter((d) => d.departmentId !== editItem?.departmentId).map((d) => (
|
||||
<SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
|
||||
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by code or name…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search departments"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
||||
{!error && items !== null && items.length === 0 && (
|
||||
|
||||
{!error && items === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="ml-auto h-7 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No departments match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">No departments yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Parent</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((d) => (
|
||||
<TableRow key={d.departmentId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{d.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{d.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{d.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(d)}>{d.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((d) => (
|
||||
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${d.name}`}
|
||||
title={`Edit ${d.name}`}
|
||||
onClick={() => openEdit(d)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
d.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||
disabled={actionPendingId === d.departmentId}
|
||||
onClick={() => toggleStatus(d)}
|
||||
>
|
||||
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${d.name}`}
|
||||
title={`Delete ${d.name}`}
|
||||
disabled={actionPendingId === d.departmentId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${d.name}?`}
|
||||
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(d)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { HrDocumentCategory, HrDocumentType } from "@/types/hrm"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -20,7 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
|
||||
|
||||
export default function DocumentTypesPage() {
|
||||
@@ -28,6 +29,10 @@ export default function DocumentTypesPage() {
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -38,13 +43,32 @@ export default function DocumentTypesPage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [editItem, setEditItem] = useState<HrDocumentType | null>(null)
|
||||
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editCategory, setEditCategory] = useState<HrDocumentCategory>("Identity")
|
||||
const [editRequiredAtOnboarding, setEditRequiredAtOnboarding] = useState(false)
|
||||
const [editExpiryTracked, setEditExpiryTracked] = useState(false)
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
function load() {
|
||||
hrDocumentTypesApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
useEffect(load, [page])
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
@@ -68,12 +92,73 @@ export default function DocumentTypesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(item: HrDocumentType) {
|
||||
setEditItem(item)
|
||||
setEditName(item.name)
|
||||
setEditCategory(item.category)
|
||||
setEditRequiredAtOnboarding(item.requiredAtOnboarding)
|
||||
setEditExpiryTracked(item.expiryTracked)
|
||||
setEditErrors({})
|
||||
setEditEtag(null)
|
||||
setEditLoading(true)
|
||||
try {
|
||||
const res = await hrDocumentTypesApi.get(item.hrDocumentTypeId)
|
||||
setEditEtag(res.etag)
|
||||
} catch (err) {
|
||||
toast.error("Could not load document type", errorMessage(err))
|
||||
setEditItem(null)
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editItem || !editEtag) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
await hrDocumentTypesApi.update(editItem.hrDocumentTypeId, {
|
||||
name: editName.trim(),
|
||||
category: editCategory,
|
||||
requiredAtOnboarding: editRequiredAtOnboarding,
|
||||
expiryTracked: editExpiryTracked,
|
||||
}, editEtag)
|
||||
toast.success("Document type updated")
|
||||
setEditItem(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not save", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: HrDocumentType) {
|
||||
setActionPendingId(item.hrDocumentTypeId)
|
||||
try {
|
||||
await hrDocumentTypesApi.updateStatus(item.hrDocumentTypeId, item.status === "Active" ? "Inactive" : "Active")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: HrDocumentType) {
|
||||
setActionPendingId(item.hrDocumentTypeId)
|
||||
try {
|
||||
await hrDocumentTypesApi.remove(item.hrDocumentTypeId)
|
||||
toast.success("Document type deleted")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete document type", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +166,14 @@ export default function DocumentTypesPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Document Types</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Document Types</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">The staff document catalog — NIC, contracts, certificates, etc.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -128,46 +220,259 @@ export default function DocumentTypesPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit Document Type</DialogTitle>
|
||||
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="dt-edit-code">Code</FieldLabel>
|
||||
<Input id="dt-edit-code" value={editItem?.code ?? ""} disabled />
|
||||
</Field>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="dt-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="dt-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="dt-edit-category">Category</FieldLabel>
|
||||
<Select value={editCategory} onValueChange={(v) => setEditCategory((v as HrDocumentCategory) ?? "Other")} disabled={editLoading}>
|
||||
<SelectTrigger id="dt-edit-category"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="dt-edit-required" checked={editRequiredAtOnboarding} onCheckedChange={(v) => setEditRequiredAtOnboarding(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="dt-edit-required">Required at onboarding</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="dt-edit-expiry" checked={editExpiryTracked} onCheckedChange={(v) => setEditExpiryTracked(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by code or name…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search document types"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
||||
{!error && items !== null && items.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No document types yet.</p></div>
|
||||
|
||||
{!error && items === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="ml-auto h-7 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No document types match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">No document types yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.hrDocumentTypeId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{t.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{t.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{t.category}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{t.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(t)}>{t.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${t.name}`}
|
||||
title={`Edit ${t.name}`}
|
||||
onClick={() => openEdit(t)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
t.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||
onClick={() => toggleStatus(t)}
|
||||
>
|
||||
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${t.name}`}
|
||||
title={`Delete ${t.name}`}
|
||||
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${t.name}?`}
|
||||
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(t)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { LeaveType } from "@/types/hrm"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -19,13 +20,17 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
|
||||
export default function LeaveTypesPage() {
|
||||
const [items, setItems] = useState<LeaveType[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -37,13 +42,34 @@ export default function LeaveTypesPage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [editItem, setEditItem] = useState<LeaveType | null>(null)
|
||||
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editIsPaid, setEditIsPaid] = useState(true)
|
||||
const [editCountsAsNoPay, setEditCountsAsNoPay] = useState(false)
|
||||
const [editAccrualPerYear, setEditAccrualPerYear] = useState(14)
|
||||
const [editCarryForwardAllowed, setEditCarryForwardAllowed] = useState(false)
|
||||
const [editRequiresApproval, setEditRequiresApproval] = useState(true)
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
function load() {
|
||||
leaveTypesApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
useEffect(load, [page])
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
@@ -70,12 +96,78 @@ export default function LeaveTypesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(item: LeaveType) {
|
||||
setEditItem(item)
|
||||
setEditName(item.name)
|
||||
setEditIsPaid(item.isPaid)
|
||||
setEditCountsAsNoPay(item.countsAsNoPay)
|
||||
setEditAccrualPerYear(item.accrualPerYear)
|
||||
setEditCarryForwardAllowed(item.carryForwardAllowed)
|
||||
setEditRequiresApproval(item.requiresApproval)
|
||||
setEditErrors({})
|
||||
setEditEtag(null)
|
||||
setEditLoading(true)
|
||||
try {
|
||||
const res = await leaveTypesApi.get(item.leaveTypeId)
|
||||
setEditEtag(res.etag)
|
||||
} catch (err) {
|
||||
toast.error("Could not load leave type", errorMessage(err))
|
||||
setEditItem(null)
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editItem || !editEtag) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
await leaveTypesApi.update(editItem.leaveTypeId, {
|
||||
name: editName.trim(),
|
||||
isPaid: editIsPaid,
|
||||
countsAsNoPay: editCountsAsNoPay,
|
||||
accrualPerYear: editAccrualPerYear,
|
||||
carryForwardAllowed: editCarryForwardAllowed,
|
||||
maxCarryForwardDays: editItem.maxCarryForwardDays,
|
||||
requiresApproval: editRequiresApproval,
|
||||
}, editEtag)
|
||||
toast.success("Leave type updated")
|
||||
setEditItem(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not save", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: LeaveType) {
|
||||
setActionPendingId(item.leaveTypeId)
|
||||
try {
|
||||
await leaveTypesApi.updateStatus(item.leaveTypeId, item.status === "Active" ? "Inactive" : "Active")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: LeaveType) {
|
||||
setActionPendingId(item.leaveTypeId)
|
||||
try {
|
||||
await leaveTypesApi.remove(item.leaveTypeId)
|
||||
toast.success("Leave type deleted")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete leave type", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +175,14 @@ export default function LeaveTypesPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Leave Types</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Leave Types</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Annual, Casual, Medical, Unpaid, etc. — drives Attendance's OnLeave classification and Payroll's No-Pay calc.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -129,48 +228,272 @@ export default function LeaveTypesPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit Leave Type</DialogTitle>
|
||||
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lt-edit-code">Code</FieldLabel>
|
||||
<Input id="lt-edit-code" value={editItem?.code ?? ""} disabled />
|
||||
</Field>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="lt-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="lt-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lt-edit-accrual">Days per year</FieldLabel>
|
||||
<Input
|
||||
id="lt-edit-accrual"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editAccrualPerYear}
|
||||
onChange={(e) => setEditAccrualPerYear(Number(e.target.value))}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="lt-edit-paid" checked={editIsPaid} onCheckedChange={(v) => setEditIsPaid(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="lt-edit-paid">Paid leave</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="lt-edit-nopay" checked={editCountsAsNoPay} onCheckedChange={(v) => setEditCountsAsNoPay(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="lt-edit-nopay">Counts as No-Pay in payroll</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="lt-edit-carry" checked={editCarryForwardAllowed} onCheckedChange={(v) => setEditCarryForwardAllowed(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="lt-edit-carry">Carry-forward allowed</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="lt-edit-approval" checked={editRequiresApproval} onCheckedChange={(v) => setEditRequiresApproval(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by code or name…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search leave types"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
||||
{!error && items !== null && items.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No leave types yet.</p></div>
|
||||
|
||||
{!error && items === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="ml-auto h-7 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No leave types match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">No leave types yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Days/yr</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Paid</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.leaveTypeId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{t.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{t.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{t.accrualPerYear}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{t.isPaid ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{t.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(t)}>{t.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
|
||||
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${t.name}`}
|
||||
title={`Edit ${t.name}`}
|
||||
onClick={() => openEdit(t)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
t.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||
disabled={actionPendingId === t.leaveTypeId}
|
||||
onClick={() => toggleStatus(t)}
|
||||
>
|
||||
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${t.name}`}
|
||||
title={`Delete ${t.name}`}
|
||||
disabled={actionPendingId === t.leaveTypeId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${t.name}?`}
|
||||
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(t)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { SalaryComponent, SalaryComponentType } from "@/types/hrm"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -20,13 +21,17 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
|
||||
export default function SalaryComponentsPage() {
|
||||
const [items, setItems] = useState<SalaryComponent[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -37,13 +42,31 @@ export default function SalaryComponentsPage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [editItem, setEditItem] = useState<SalaryComponent | null>(null)
|
||||
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editIsTaxable, setEditIsTaxable] = useState(true)
|
||||
const [editIsEpfEtfApplicable, setEditIsEpfEtfApplicable] = useState(true)
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
function load() {
|
||||
salaryComponentsApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
useEffect(load, [page])
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
@@ -67,12 +90,72 @@ export default function SalaryComponentsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(item: SalaryComponent) {
|
||||
setEditItem(item)
|
||||
setEditName(item.name)
|
||||
setEditIsTaxable(item.isTaxable)
|
||||
setEditIsEpfEtfApplicable(item.isEpfEtfApplicable)
|
||||
setEditErrors({})
|
||||
setEditEtag(null)
|
||||
setEditLoading(true)
|
||||
try {
|
||||
const res = await salaryComponentsApi.get(item.salaryComponentId)
|
||||
setEditEtag(res.etag)
|
||||
} catch (err) {
|
||||
toast.error("Could not load salary component", errorMessage(err))
|
||||
setEditItem(null)
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editItem || !editEtag) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
await salaryComponentsApi.update(editItem.salaryComponentId, {
|
||||
name: editName.trim(),
|
||||
componentType: editItem.componentType,
|
||||
isTaxable: editIsTaxable,
|
||||
isEpfEtfApplicable: editIsEpfEtfApplicable,
|
||||
}, editEtag)
|
||||
toast.success("Salary component updated")
|
||||
setEditItem(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not save", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: SalaryComponent) {
|
||||
setActionPendingId(item.salaryComponentId)
|
||||
try {
|
||||
await salaryComponentsApi.updateStatus(item.salaryComponentId, item.status === "Active" ? "Inactive" : "Active")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: SalaryComponent) {
|
||||
setActionPendingId(item.salaryComponentId)
|
||||
try {
|
||||
await salaryComponentsApi.remove(item.salaryComponentId)
|
||||
toast.success("Salary component deleted")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete salary component", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +163,14 @@ export default function SalaryComponentsPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Salary Components</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Salary Components</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Allowances and ad hoc other deductions — OT/Late/No-Pay/Loan/EPF/ETF/Tax are computed automatically, not components.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -128,46 +218,254 @@ export default function SalaryComponentsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit Salary Component</DialogTitle>
|
||||
<DialogDescription>Code and type can't be changed after creation.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sc-edit-code">Code</FieldLabel>
|
||||
<Input id="sc-edit-code" value={editItem?.code ?? ""} disabled />
|
||||
</Field>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="sc-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="sc-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sc-edit-type">Type</FieldLabel>
|
||||
<Input id="sc-edit-type" value={editItem?.componentType ?? ""} disabled />
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="sc-edit-taxable" checked={editIsTaxable} onCheckedChange={(v) => setEditIsTaxable(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="sc-edit-taxable">Taxable</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="sc-edit-epfetf" checked={editIsEpfEtfApplicable} onCheckedChange={(v) => setEditIsEpfEtfApplicable(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by code or name…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search salary components"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
||||
{!error && items !== null && items.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No salary components yet.</p></div>
|
||||
|
||||
{!error && items === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="ml-auto h-7 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No salary components match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">No salary components yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((c) => (
|
||||
<TableRow key={c.salaryComponentId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{c.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{c.componentType}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{c.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(c)}>{c.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((c) => (
|
||||
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${c.name}`}
|
||||
title={`Edit ${c.name}`}
|
||||
onClick={() => openEdit(c)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
c.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||
disabled={actionPendingId === c.salaryComponentId}
|
||||
onClick={() => toggleStatus(c)}
|
||||
>
|
||||
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${c.name}`}
|
||||
title={`Delete ${c.name}`}
|
||||
disabled={actionPendingId === c.salaryComponentId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${c.name}?`}
|
||||
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,12 +11,13 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function StatutorySettingsPage() {
|
||||
const [settings, setSettings] = useState<PayrollStatutorySetting[]>([])
|
||||
const [slabs, setSlabs] = useState<TaxSlab[]>([])
|
||||
const [settings, setSettings] = useState<PayrollStatutorySetting[] | null>(null)
|
||||
const [slabs, setSlabs] = useState<TaxSlab[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [settingOpen, setSettingOpen] = useState(false)
|
||||
@@ -86,7 +87,14 @@ export default function StatutorySettingsPage() {
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">EPF / ETF Rates</h2>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 className="text-lg font-semibold text-foreground">EPF / ETF Rates</h2>
|
||||
{settings && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{settings.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={settingOpen} onOpenChange={setSettingOpen}>
|
||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Setting</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
@@ -108,33 +116,72 @@ export default function StatutorySettingsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">EPF (Employee)</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">EPF (Employer)</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">ETF (Employer)</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">OT multiplier</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settings.map((s) => (
|
||||
<TableRow key={s.payrollStatutorySettingId}>
|
||||
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{s.otMultiplierDefault}x</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{settings === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings !== null && settings.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-16 text-center">
|
||||
<p className="text-base text-muted-foreground">No statutory settings recorded yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings !== null && settings.length > 0 && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settings.map((s) => (
|
||||
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">
|
||||
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Tax Slabs</h2>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 className="text-lg font-semibold text-foreground">Tax Slabs</h2>
|
||||
{slabs && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{slabs.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={slabOpen} onOpenChange={setSlabOpen}>
|
||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Slab</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
@@ -155,24 +202,52 @@ export default function StatutorySettingsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{slabs.map((s) => (
|
||||
<TableRow key={s.taxSlabId}>
|
||||
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{(s.rate * 100).toFixed(1)}%</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{slabs === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slabs !== null && slabs.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-16 text-center">
|
||||
<p className="text-base text-muted-foreground">No tax slabs recorded yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slabs !== null && slabs.length > 0 && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{slabs.map((s) => (
|
||||
<TableRow key={s.taxSlabId} className="hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { workShiftsApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { WorkShift } from "@/types/hrm"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -19,14 +20,22 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
|
||||
function maskToDays(mask: number): boolean[] {
|
||||
return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
|
||||
}
|
||||
|
||||
export default function WorkShiftsPage() {
|
||||
const [items, setItems] = useState<WorkShift[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -42,9 +51,24 @@ export default function WorkShiftsPage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [editItem, setEditItem] = useState<WorkShift | null>(null)
|
||||
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editStartTime, setEditStartTime] = useState("08:00")
|
||||
const [editEndTime, setEditEndTime] = useState("17:00")
|
||||
const [editIsOvernight, setEditIsOvernight] = useState(false)
|
||||
const [editGraceMinutes, setEditGraceMinutes] = useState(15)
|
||||
const [editBreakMinutes, setEditBreakMinutes] = useState(60)
|
||||
const [editStandardWorkingMinutes, setEditStandardWorkingMinutes] = useState(480)
|
||||
const [editOtMultiplier, setEditOtMultiplier] = useState(1.5)
|
||||
const [editWorkingDays, setEditWorkingDays] = useState<boolean[]>([true, true, true, true, true, false, false])
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
function load() {
|
||||
workShiftsApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => {
|
||||
setItems(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -52,7 +76,15 @@ export default function WorkShiftsPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page])
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
@@ -88,12 +120,84 @@ export default function WorkShiftsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(item: WorkShift) {
|
||||
setEditItem(item)
|
||||
setEditName(item.name)
|
||||
setEditStartTime(item.startTime.slice(0, 5))
|
||||
setEditEndTime(item.endTime.slice(0, 5))
|
||||
setEditIsOvernight(item.isOvernight)
|
||||
setEditGraceMinutes(item.graceMinutes)
|
||||
setEditBreakMinutes(item.breakMinutes)
|
||||
setEditStandardWorkingMinutes(item.standardWorkingMinutes)
|
||||
setEditOtMultiplier(item.otMultiplier)
|
||||
setEditWorkingDays(maskToDays(item.workingDaysMask))
|
||||
setEditErrors({})
|
||||
setEditEtag(null)
|
||||
setEditLoading(true)
|
||||
try {
|
||||
const res = await workShiftsApi.get(item.workShiftId)
|
||||
setEditEtag(res.etag)
|
||||
} catch (err) {
|
||||
toast.error("Could not load work shift", errorMessage(err))
|
||||
setEditItem(null)
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editItem || !editEtag) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
const mask = editWorkingDays.reduce((m, on, i) => (on ? m | (1 << i) : m), 0)
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
await workShiftsApi.update(editItem.workShiftId, {
|
||||
name: editName.trim(),
|
||||
startTime: `${editStartTime}:00`,
|
||||
endTime: `${editEndTime}:00`,
|
||||
isOvernight: editIsOvernight,
|
||||
graceMinutes: editGraceMinutes,
|
||||
breakMinutes: editBreakMinutes,
|
||||
standardWorkingMinutes: editStandardWorkingMinutes,
|
||||
otMultiplier: editOtMultiplier,
|
||||
workingDaysMask: mask,
|
||||
}, editEtag)
|
||||
toast.success("Work shift updated")
|
||||
setEditItem(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not save", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: WorkShift) {
|
||||
setActionPendingId(item.workShiftId)
|
||||
try {
|
||||
await workShiftsApi.updateStatus(item.workShiftId, item.status === "Active" ? "Inactive" : "Active")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: WorkShift) {
|
||||
setActionPendingId(item.workShiftId)
|
||||
try {
|
||||
await workShiftsApi.remove(item.workShiftId)
|
||||
toast.success("Work shift deleted")
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete work shift", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +205,14 @@ export default function WorkShiftsPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Work Shifts</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Work Shifts</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">The baseline Attendance computes Late/Early/OT against.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -174,46 +285,289 @@ export default function WorkShiftsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit Work Shift</DialogTitle>
|
||||
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-edit-code">Code</FieldLabel>
|
||||
<Input id="w-edit-code" value={editItem?.code ?? ""} disabled />
|
||||
</Field>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="w-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="w-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-edit-start">Start time</FieldLabel>
|
||||
<Input id="w-edit-start" type="time" value={editStartTime} onChange={(e) => setEditStartTime(e.target.value)} disabled={editLoading} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-edit-end">End time</FieldLabel>
|
||||
<Input id="w-edit-end" type="time" value={editEndTime} onChange={(e) => setEditEndTime(e.target.value)} disabled={editLoading} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="w-edit-overnight" checked={editIsOvernight} onCheckedChange={(v) => setEditIsOvernight(v === true)} disabled={editLoading} />
|
||||
<FieldLabel htmlFor="w-edit-overnight">Overnight shift (end time rolls past midnight)</FieldLabel>
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-edit-grace">Grace (minutes)</FieldLabel>
|
||||
<Input id="w-edit-grace" type="number" min={0} value={editGraceMinutes} onChange={(e) => setEditGraceMinutes(Number(e.target.value))} disabled={editLoading} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-edit-break">Break (minutes)</FieldLabel>
|
||||
<Input id="w-edit-break" type="number" min={0} value={editBreakMinutes} onChange={(e) => setEditBreakMinutes(Number(e.target.value))} disabled={editLoading} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-edit-standard">Standard working minutes</FieldLabel>
|
||||
<Input id="w-edit-standard" type="number" min={1} value={editStandardWorkingMinutes} onChange={(e) => setEditStandardWorkingMinutes(Number(e.target.value))} disabled={editLoading} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-edit-ot">OT multiplier</FieldLabel>
|
||||
<Input id="w-edit-ot" type="number" step="0.1" min={1} value={editOtMultiplier} onChange={(e) => setEditOtMultiplier(Number(e.target.value))} disabled={editLoading} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>Working days</FieldLabel>
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
{DAYS.map((d, i) => (
|
||||
<label key={d} className="flex items-center gap-1.5 text-sm">
|
||||
<Checkbox
|
||||
checked={editWorkingDays[i]}
|
||||
onCheckedChange={(v) => setEditWorkingDays((prev) => prev.map((p, idx) => (idx === i ? v === true : p)))}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
{d}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by code or name…"
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label="Search work shifts"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
||||
{!error && items !== null && items.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No work shifts yet.</p></div>
|
||||
|
||||
{!error && items === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="ml-auto h-7 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No work shifts match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">No work shifts yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Hours</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((w) => (
|
||||
<TableRow key={w.workShiftId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{w.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{w.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(w)}>{w.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((w) => (
|
||||
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{w.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${w.name}`}
|
||||
title={`Edit ${w.name}`}
|
||||
onClick={() => openEdit(w)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
w.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||
disabled={actionPendingId === w.workShiftId}
|
||||
onClick={() => toggleStatus(w)}
|
||||
>
|
||||
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${w.name}`}
|
||||
title={`Delete ${w.name}`}
|
||||
disabled={actionPendingId === w.workShiftId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${w.name}?`}
|
||||
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(w)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,15 +11,15 @@ export default function DashboardLayout({
|
||||
}) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<div className="flex h-screen overflow-hidden bg-background print:block print:h-auto print:overflow-visible">
|
||||
<AppSidebar />
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<Header />
|
||||
<div className="min-w-0 flex-1 overflow-auto">
|
||||
<div className="min-w-0 px-6 pt-3 pb-6 lg:px-8 lg:pt-4 lg:pb-8">
|
||||
<div className="min-w-0 flex-1 overflow-auto print:overflow-visible">
|
||||
<div className="min-w-0 px-6 pt-3 pb-6 lg:px-8 lg:pt-4 lg:pb-8 print:p-0">
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-border shadow-sm">
|
||||
<div className="p-6">
|
||||
<div className="rounded-xl bg-card border border-border shadow-sm print:rounded-none print:border-0 print:shadow-none">
|
||||
<div className="p-6 print:p-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { ManagedUser } from "@/types/users"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const ACTIVITY_PAGE_SIZE = 8
|
||||
const DEVICES_PAGE_SIZE = 8
|
||||
@@ -32,6 +34,8 @@ export default function UserDetailPage() {
|
||||
const [devices, setDevices] = useState<LoginActivity[] | null>(null)
|
||||
const [devicesError, setDevicesError] = useState<string | null>(null)
|
||||
|
||||
const [statusPending, setStatusPending] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(userId)) return
|
||||
setLoadError(null)
|
||||
@@ -74,9 +78,24 @@ export default function UserDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
async function handleToggleStatus() {
|
||||
if (!user) return
|
||||
const nextStatus = user.status === "Active" ? "Inactive" : "Active"
|
||||
setStatusPending(true)
|
||||
try {
|
||||
const updated = await usersApi.updateStatus(user.userId, { status: nextStatus })
|
||||
setUser(updated)
|
||||
toast.success(nextStatus === "Active" ? "User activated" : "User deactivated")
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setStatusPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
|
||||
@@ -92,6 +111,18 @@ export default function UserDetailPage() {
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
|
||||
{user.status === "Active" ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={user.status === "Active"}
|
||||
onCheckedChange={handleToggleStatus}
|
||||
disabled={statusPending}
|
||||
aria-label={user.status === "Active" ? "Deactivate user" : "Activate user"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||
|
||||
@@ -268,7 +268,7 @@ function SidebarContent({
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"sidebar-surface flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out",
|
||||
"sidebar-surface flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out print:hidden",
|
||||
!isMobile && (collapsed ? "w-20" : "w-64")
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function Breadcrumbs() {
|
||||
}))
|
||||
|
||||
return (
|
||||
<Breadcrumb className="mb-4">
|
||||
<Breadcrumb className="mb-4 print:hidden">
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, i) => {
|
||||
const isLast = i === crumbs.length - 1
|
||||
|
||||
@@ -219,7 +219,7 @@ export function Header() {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="mx-3 mt-3 mb-3 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:gap-4 lg:p-4">
|
||||
<header className="print:hidden mx-3 mt-3 mb-3 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:gap-4 lg:p-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||
{showBackButton && (
|
||||
<button
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
// (Branch, Designation, EmploymentType) — identical shape to each other, so one
|
||||
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ApiResult, EntityStatus, PaginationMeta } from "@/types/common"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -19,16 +20,22 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
|
||||
interface CodeNamed {
|
||||
code: string
|
||||
name: string
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Api<T extends CodeNamed> {
|
||||
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
|
||||
list(params: { page: number; pageSize: number; q?: string }): Promise<{ items: T[]; pagination: PaginationMeta }>
|
||||
get(id: number): Promise<ApiResult<T>>
|
||||
create(request: { code: string; name: string }): Promise<ApiResult<T>>
|
||||
update(id: number, request: { name: string }, ifMatch: string): Promise<ApiResult<T>>
|
||||
updateStatus(id: number, status: EntityStatus): Promise<void>
|
||||
remove(id: number): Promise<void>
|
||||
}
|
||||
|
||||
export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
@@ -42,11 +49,15 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
idOf: (item: T) => number
|
||||
api: Api<T>
|
||||
}) {
|
||||
const PAGE_SIZE = 10
|
||||
const singular = title.replace(/s$/, "")
|
||||
const [items, setItems] = useState<T[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
@@ -54,9 +65,16 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [editItem, setEditItem] = useState<T | null>(null)
|
||||
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
function load() {
|
||||
api
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.then((res) => {
|
||||
setItems(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -64,7 +82,15 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
setDebouncedSearch(search.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
@@ -76,7 +102,7 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.create({ code: code.trim(), name: name.trim() })
|
||||
toast.success(`${title.replace(/s$/, "")} created`)
|
||||
toast.success(`${singular} created`)
|
||||
setOpen(false)
|
||||
setCode("")
|
||||
setName("")
|
||||
@@ -89,29 +115,90 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(item: T) {
|
||||
setEditItem(item)
|
||||
setEditName(item.name)
|
||||
setEditErrors({})
|
||||
setEditEtag(null)
|
||||
setEditLoading(true)
|
||||
try {
|
||||
const res = await api.get(idOf(item))
|
||||
setEditEtag(res.etag)
|
||||
} catch (err) {
|
||||
toast.error("Could not load record", errorMessage(err))
|
||||
setEditItem(null)
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editItem || !editEtag) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
await api.update(idOf(editItem), { name: editName.trim() }, editEtag)
|
||||
toast.success(`${singular} updated`)
|
||||
setEditItem(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not save", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: T) {
|
||||
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
|
||||
setActionPendingId(idOf(item))
|
||||
try {
|
||||
await api.updateStatus(idOf(item), next)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: T) {
|
||||
setActionPendingId(idOf(item))
|
||||
try {
|
||||
await api.remove(idOf(item))
|
||||
toast.success(`${singular} deleted`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New</Button>} />
|
||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New {singular}</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New {title.replace(/s$/, "")}</DialogTitle>
|
||||
<DialogDescription>Deactivate later — masters are never deleted.</DialogDescription>
|
||||
<DialogTitle>New {singular}</DialogTitle>
|
||||
<DialogDescription>You can deactivate or delete it later.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
@@ -133,58 +220,239 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit {singular}</DialogTitle>
|
||||
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="m-edit-code">Code</FieldLabel>
|
||||
<Input id="m-edit-code" value={editItem?.code ?? ""} disabled />
|
||||
</Field>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="m-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="m-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={`Search by code or name…`}
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label={`Search ${title.toLowerCase()}`}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
{!error && items === null && (
|
||||
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="ml-auto h-7 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && (
|
||||
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No {title.toLowerCase()} match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">No records yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items !== null && items.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={idOf(item)}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{item.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(item)}>
|
||||
{item.status === "Active" ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 w-1/5 px-4 text-sm">Code</TableHead>
|
||||
<TableHead className="h-11 w-1/5 px-4 text-sm">Name</TableHead>
|
||||
<TableHead className="h-11 w-1/5 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-1/5 px-4 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-1/5 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={idOf(item)} className="group align-top hover:bg-muted/20">
|
||||
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{item.code}</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-foreground">{item.name}</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||
item.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{new Date(item.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${item.name}`}
|
||||
title={`Edit ${item.name}`}
|
||||
onClick={() => openEdit(item)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
item.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-success hover:bg-success/10"
|
||||
)}
|
||||
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
||||
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
||||
disabled={actionPendingId === idOf(item)}
|
||||
onClick={() => toggleStatus(item)}
|
||||
>
|
||||
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||
aria-label={`Delete ${item.name}`}
|
||||
title={`Delete ${item.name}`}
|
||||
disabled={actionPendingId === idOf(item)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${item.name}?`}
|
||||
description="This permanently removes this record. This cannot be undone. Blocked if it's referenced by other records — deactivate it instead in that case."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(item)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> {title.toLowerCase()}
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LeaveRequest, LeaveRequestStatus } from "@/types/hrm"
|
||||
export interface ListLeaveRequestsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
employeeId?: number
|
||||
status?: LeaveRequestStatus
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Minimal single-page PDF writer — no dependency (jspdf/pdf-lib) needed.
|
||||
// Builds a valid PDF byte-for-byte using the base14 Helvetica font, which every
|
||||
// PDF reader supports without embedding. Good enough for text/table documents
|
||||
// like a payslip breakdown; not a general-purpose PDF library.
|
||||
|
||||
export interface PdfLine {
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
size?: number
|
||||
bold?: boolean
|
||||
color?: [number, number, number] // 0-1 RGB
|
||||
}
|
||||
|
||||
const PAGE_WIDTH = 612 // US Letter, points
|
||||
const PAGE_HEIGHT = 792
|
||||
|
||||
function escapePdfText(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")
|
||||
}
|
||||
|
||||
/** Builds a one-page PDF from absolutely-positioned text lines (origin bottom-left, like PDF space). */
|
||||
export function buildSimplePdf(lines: PdfLine[]): Blob {
|
||||
const content: string[] = ["q"]
|
||||
let currentFont: "F1" | "F2" | null = null
|
||||
let currentSize: number | null = null
|
||||
let currentColor: string | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
const font = line.bold ? "F2" : "F1"
|
||||
const size = line.size ?? 11
|
||||
const [r, g, b] = line.color ?? [0.07, 0.07, 0.07]
|
||||
const colorKey = `${r},${g},${b}`
|
||||
|
||||
if (colorKey !== currentColor) {
|
||||
content.push(`${r.toFixed(3)} ${g.toFixed(3)} ${b.toFixed(3)} rg`)
|
||||
currentColor = colorKey
|
||||
}
|
||||
content.push("BT")
|
||||
if (font !== currentFont || size !== currentSize) {
|
||||
content.push(`/${font} ${size} Tf`)
|
||||
currentFont = font
|
||||
currentSize = size
|
||||
}
|
||||
content.push(`${line.x.toFixed(2)} ${line.y.toFixed(2)} Td`)
|
||||
content.push(`(${escapePdfText(line.text)}) Tj`)
|
||||
content.push("ET")
|
||||
}
|
||||
content.push("Q")
|
||||
const contentStream = content.join("\n")
|
||||
|
||||
const objects: string[] = []
|
||||
objects.push("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
objects.push("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
||||
objects.push(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`
|
||||
)
|
||||
objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
||||
objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>")
|
||||
objects.push(`<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream`)
|
||||
|
||||
let pdf = "%PDF-1.4\n"
|
||||
const offsets: number[] = [0]
|
||||
objects.forEach((obj, i) => {
|
||||
offsets.push(pdf.length)
|
||||
pdf += `${i + 1} 0 obj\n${obj}\nendobj\n`
|
||||
})
|
||||
|
||||
const xrefStart = pdf.length
|
||||
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
|
||||
for (let i = 1; i <= objects.length; i++) {
|
||||
xref += `${offsets[i].toString().padStart(10, "0")} 00000 n \n`
|
||||
}
|
||||
pdf += xref
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF`
|
||||
|
||||
return new Blob([pdf], { type: "application/pdf" })
|
||||
}
|
||||
|
||||
export function downloadPdf(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
Reference in New Issue
Block a user