develop full initial module

This commit is contained in:
Dhananjaya99
2026-07-23 19:54:56 +05:30
parent eacc21afad
commit 755df494fe
188 changed files with 13894 additions and 49 deletions
+5 -2
View File
@@ -50,10 +50,13 @@ export function readCsrfToken(): string | null {
async function rawRequest(path: string, options: RequestOptions = {}): Promise<Response> {
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
const csrfToken = csrf ? readCsrfToken() : null
// Multipart uploads (attendance files, staff documents) pass a FormData body — the
// browser sets its own Content-Type (with boundary), and it must never be JSON-encoded.
const isFormData = typeof FormData !== "undefined" && body instanceof FormData
const finalHeaders: Record<string, string> = {
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(body !== undefined && !isFormData ? { "Content-Type": "application/json" } : {}),
...(ifMatch ? { "If-Match": ifMatch } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
...(csrfToken ? { "X-XSRF-TOKEN": csrfToken } : {}),
@@ -64,7 +67,7 @@ async function rawRequest(path: string, options: RequestOptions = {}): Promise<R
...rest,
credentials: "include", // sends erp_at; the whole auth story depends on this
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
body: isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
+57
View File
@@ -0,0 +1,57 @@
// Attendance upload/validate/confirm pipeline (docs/13-BACKEND-HRM-API.md §4).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { AttendanceBatchStatus, AttendanceRecord, AttendanceStatusValue, AttendanceUploadBatch, RowValidationStatus } from "@/types/hrm"
export interface ListAttendanceBatchesParams {
page?: number
pageSize?: number
status?: AttendanceBatchStatus
periodYear?: number
periodMonth?: number
}
/** Direct-link download — the browser navigates/streams the binary response itself. */
export function attendanceTemplateUrl(format?: "csv"): string {
return `/api/v1/attendance-batches/template.xlsx${format ? "?format=csv" : ""}`
}
export const attendanceApi = {
list(params: ListAttendanceBatchesParams = {}): Promise<PagedResponse<AttendanceUploadBatch>> {
return apiRequest<PagedResponse<AttendanceUploadBatch>>(`/attendance-batches${buildQuery(params)}`)
},
get(batchId: number): Promise<AttendanceUploadBatch> {
return apiRequest<AttendanceUploadBatch>(`/attendance-batches/${batchId}`)
},
upload(file: File, periodStart: string, periodEnd: string): Promise<AttendanceUploadBatch> {
const form = new FormData()
form.append("file", file)
form.append("PeriodStart", periodStart)
form.append("PeriodEnd", periodEnd)
return apiRequest<AttendanceUploadBatch>("/attendance-batches", { method: "POST", body: form })
},
listRecords(batchId: number, status?: RowValidationStatus): Promise<AttendanceRecord[]> {
return apiRequest<AttendanceRecord[]>(`/attendance-batches/${batchId}/records${buildQuery({ status })}`)
},
updateRecord(
batchId: number,
recordId: number,
request: { checkIn?: string | null; checkOut?: string | null; attendanceStatus?: AttendanceStatusValue | null; notes?: string | null }
): Promise<AttendanceRecord> {
return apiRequest<AttendanceRecord>(`/attendance-batches/${batchId}/records/${recordId}`, { method: "PUT", body: request })
},
resolveDuplicate(batchId: number, recordId: number, action: "keep" | "discard" | "supersede"): Promise<void> {
return apiRequest<void>(`/attendance-batches/${batchId}/resolve-duplicate`, { method: "POST", body: { recordId, action } })
},
validate(batchId: number): Promise<AttendanceUploadBatch> {
return apiRequest<AttendanceUploadBatch>(`/attendance-batches/${batchId}/validate`, { method: "POST" })
},
confirm(batchId: number): Promise<AttendanceUploadBatch> {
return apiRequest<AttendanceUploadBatch>(`/attendance-batches/${batchId}/confirm`, { method: "POST" })
},
unlock(batchId: number, reason: string): Promise<AttendanceUploadBatch> {
return apiRequest<AttendanceUploadBatch>(`/attendance-batches/${batchId}/unlock`, { method: "POST", body: { reason } })
},
}
+128
View File
@@ -0,0 +1,128 @@
// Employee (staff) endpoints, incl. the Employee<->User cross-link, bank details,
// documents, salary structure, loans, and leave balances (docs/13-BACKEND-HRM-API.md §3).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CreateEmployeeRequest,
EmployeeBankDetail,
EmployeeDetail,
EmployeeDocument,
EmployeeListItem,
EmployeeLoan,
EmployeeMatch,
EmployeeSalaryStructure,
EmployeeStatus,
LeaveBalance,
UpdateEmployeeRequest,
UserMatch,
} from "@/types/hrm"
export interface ListEmployeesParams {
page?: number
pageSize?: number
q?: string
status?: EmployeeStatus
departmentId?: number
designationId?: number
branchId?: number
}
export interface CreateSalaryStructureRequest {
effectiveFrom: string
basicSalary: number
lines: { salaryComponentId: number; amount: number }[]
}
export interface CreateEmployeeLoanRequest {
loanKind: "Loan" | "Advance"
principalAmount: number
interestRate: number
installmentAmount: number
numberOfInstallments: number
startYear: number
startMonth: number
}
export const employeesApi = {
list(params: ListEmployeesParams = {}): Promise<PagedResponse<EmployeeListItem>> {
return apiRequest<PagedResponse<EmployeeListItem>>(`/employees${buildQuery(params)}`)
},
get(employeeId: number): Promise<ApiResult<EmployeeDetail>> {
return apiRequestWithETag<EmployeeDetail>(`/employees/${employeeId}`)
},
create(request: CreateEmployeeRequest): Promise<ApiResult<EmployeeDetail>> {
return apiRequestWithETag<EmployeeDetail>("/employees", { method: "POST", body: request })
},
update(employeeId: number, request: UpdateEmployeeRequest, ifMatch: string): Promise<ApiResult<EmployeeDetail>> {
return apiRequestWithETag<EmployeeDetail>(`/employees/${employeeId}`, { method: "PUT", body: request, ifMatch })
},
updateStatus(employeeId: number, status: EmployeeStatus): Promise<void> {
return apiRequest<void>(`/employees/${employeeId}/status`, { method: "PATCH", body: { status } })
},
/** Advisory: does a System User already exist with this email? */
emailLookup(email: string): Promise<{ match: UserMatch | null }> {
return apiRequest<{ match: UserMatch | null }>(`/employees/email-lookup${buildQuery({ email })}`)
},
linkUser(employeeId: number, userId: number): Promise<void> {
return apiRequest<void>(`/employees/${employeeId}/link-user`, { method: "POST", body: { userId } })
},
unlinkUser(employeeId: number): Promise<void> {
return apiRequest<void>(`/employees/${employeeId}/link-user`, { method: "DELETE" })
},
listBankDetails(employeeId: number): Promise<EmployeeBankDetail[]> {
return apiRequest<EmployeeBankDetail[]>(`/employees/${employeeId}/bank-details`)
},
replaceBankDetails(employeeId: number, items: EmployeeBankDetail[]): Promise<EmployeeBankDetail[]> {
return apiRequest<EmployeeBankDetail[]>(`/employees/${employeeId}/bank-details`, { method: "PUT", body: { items } })
},
listDocuments(employeeId: number): Promise<EmployeeDocument[]> {
return apiRequest<EmployeeDocument[]>(`/employees/${employeeId}/documents`)
},
async uploadDocument(
employeeId: number,
file: File,
meta: { hrDocumentTypeId: number; issueDate?: string | null; expiryDate?: string | null; notes?: string | null }
): Promise<EmployeeDocument> {
const form = new FormData()
form.append("file", file)
form.append("HrDocumentTypeId", String(meta.hrDocumentTypeId))
if (meta.issueDate) form.append("IssueDate", meta.issueDate)
if (meta.expiryDate) form.append("ExpiryDate", meta.expiryDate)
if (meta.notes) form.append("Notes", meta.notes)
return apiRequest<EmployeeDocument>(`/employees/${employeeId}/documents`, { method: "POST", body: form })
},
documentDownloadUrl(employeeId: number, documentId: number): string {
return `/api/v1/employees/${employeeId}/documents/${documentId}/download`
},
setDocumentStatus(employeeId: number, documentId: number, status: "Active" | "Archived"): Promise<void> {
return apiRequest<void>(`/employees/${employeeId}/documents/${documentId}/status`, { method: "PATCH", body: { status } })
},
salaryStructureHistory(employeeId: number): Promise<EmployeeSalaryStructure[]> {
return apiRequest<EmployeeSalaryStructure[]>(`/employees/${employeeId}/salary-structure`)
},
createSalaryStructure(employeeId: number, request: CreateSalaryStructureRequest): Promise<EmployeeSalaryStructure> {
return apiRequest<EmployeeSalaryStructure>(`/employees/${employeeId}/salary-structure`, { method: "POST", body: request })
},
listLoans(employeeId: number): Promise<EmployeeLoan[]> {
return apiRequest<EmployeeLoan[]>(`/employees/${employeeId}/loans`)
},
createLoan(employeeId: number, request: CreateEmployeeLoanRequest): Promise<EmployeeLoan> {
return apiRequest<EmployeeLoan>(`/employees/${employeeId}/loans`, { method: "POST", body: request })
},
listLeaveBalances(employeeId: number, year?: number): Promise<LeaveBalance[]> {
return apiRequest<LeaveBalance[]>(`/employees/${employeeId}/leave-balances${buildQuery({ year })}`)
},
}
// Re-exported for the Employee create form's cross-link chip (mirrors employeesApi.emailLookup in reverse).
export const employeeCrossLinkApi = {
findStaffByEmail(email: string): Promise<{ match: EmployeeMatch | null }> {
return apiRequest<{ match: EmployeeMatch | null }>(`/users/email-lookup${buildQuery({ email })}`)
},
}
+35
View File
@@ -0,0 +1,35 @@
// Read-only HRM reports (docs/13-BACKEND-HRM-API.md §6, FR-HR-RPT).
import { apiRequest, buildQuery } from "@/lib/api-client"
import {
AttendanceSummaryRow,
DocumentExpiryReportRow,
LateArrivalReportRow,
LeaveBalanceReportRow,
OvertimeReportRow,
PayrollRegisterRow,
SalaryHistoryRow,
} from "@/types/hrm"
export const hrReportsApi = {
attendanceSummary(periodYear: number, periodMonth: number, departmentId?: number): Promise<AttendanceSummaryRow[]> {
return apiRequest<AttendanceSummaryRow[]>(`/reports/hrm/attendance-summary${buildQuery({ periodYear, periodMonth, departmentId })}`)
},
overtime(periodYear: number, periodMonth: number): Promise<OvertimeReportRow[]> {
return apiRequest<OvertimeReportRow[]>(`/reports/hrm/overtime${buildQuery({ periodYear, periodMonth })}`)
},
lateArrivals(periodYear: number, periodMonth: number): Promise<LateArrivalReportRow[]> {
return apiRequest<LateArrivalReportRow[]>(`/reports/hrm/late-arrivals${buildQuery({ periodYear, periodMonth })}`)
},
payrollRegister(payrollRunId: number): Promise<PayrollRegisterRow[]> {
return apiRequest<PayrollRegisterRow[]>(`/reports/hrm/payroll-register${buildQuery({ payrollRunId })}`)
},
salaryHistory(employeeId: number): Promise<SalaryHistoryRow[]> {
return apiRequest<SalaryHistoryRow[]>(`/reports/hrm/salary-history${buildQuery({ employeeId })}`)
},
leaveBalances(year: number): Promise<LeaveBalanceReportRow[]> {
return apiRequest<LeaveBalanceReportRow[]>(`/reports/hrm/leave-balances${buildQuery({ year })}`)
},
documentExpiry(withinDays: number): Promise<DocumentExpiryReportRow[]> {
return apiRequest<DocumentExpiryReportRow[]>(`/reports/hrm/document-expiry${buildQuery({ withinDays })}`)
},
}
@@ -0,0 +1,32 @@
// Shared CRUD shape for the ~8 near-identical HRM masters (Branch, Department,
// Designation, EmploymentType, WorkShift, HrDocumentType, LeaveType, SalaryComponent) —
// same ETag/status/list pattern as brandsApi, factored out once instead of copy-pasted 8x.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
export interface ListMasterParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
}
export function createMasterApi<T, TCreate, TUpdate>(resource: string) {
return {
list(params: ListMasterParams = {}): Promise<PagedResponse<T>> {
return apiRequest<PagedResponse<T>>(`/${resource}${buildQuery(params)}`)
},
get(id: number): Promise<ApiResult<T>> {
return apiRequestWithETag<T>(`/${resource}/${id}`)
},
create(request: TCreate): Promise<ApiResult<T>> {
return apiRequestWithETag<T>(`/${resource}`, { method: "POST", body: request })
},
update(id: number, request: TUpdate, ifMatch: string): Promise<ApiResult<T>> {
return apiRequestWithETag<T>(`/${resource}/${id}`, { method: "PUT", body: request, ifMatch })
},
updateStatus(id: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/${resource}/${id}/status`, { method: "PATCH", body: { status } })
},
}
}
@@ -0,0 +1,83 @@
// HRM org/reference masters (docs/13-BACKEND-HRM-API.md §2, §5, §6). Each is the
// same list/get/create/update/status-toggle shape as brandsApi (see hrm-master-factory).
import { createMasterApi } from "@/lib/api/hrm-master-factory"
import {
Branch,
Department,
Designation,
EmploymentType,
HrDocumentType,
LeaveType,
SalaryComponent,
WorkShift,
} from "@/types/hrm"
export interface CreateBranchRequest { code: string; name: string; address?: string | null }
export type UpdateBranchRequest = Omit<CreateBranchRequest, "code">
export const branchesHrmApi = createMasterApi<Branch, CreateBranchRequest, UpdateBranchRequest>("branches")
export interface CreateDepartmentRequest {
code: string
name: string
parentDepartmentId?: number | null
headEmployeeId?: number | null
branchId?: number | null
}
export type UpdateDepartmentRequest = Omit<CreateDepartmentRequest, "code">
export const departmentsApi = createMasterApi<Department, CreateDepartmentRequest, UpdateDepartmentRequest>("departments")
export interface CreateDesignationRequest { code: string; name: string }
export type UpdateDesignationRequest = Omit<CreateDesignationRequest, "code">
export const designationsApi = createMasterApi<Designation, CreateDesignationRequest, UpdateDesignationRequest>("designations")
export interface CreateEmploymentTypeRequest { code: string; name: string }
export type UpdateEmploymentTypeRequest = Omit<CreateEmploymentTypeRequest, "code">
export const employmentTypesApi = createMasterApi<EmploymentType, CreateEmploymentTypeRequest, UpdateEmploymentTypeRequest>("employment-types")
export interface CreateWorkShiftRequest {
code: string
name: string
startTime: string
endTime: string
isOvernight: boolean
graceMinutes: number
breakMinutes: number
standardWorkingMinutes: number
otMultiplier: number
workingDaysMask: number
}
export type UpdateWorkShiftRequest = Omit<CreateWorkShiftRequest, "code">
export const workShiftsApi = createMasterApi<WorkShift, CreateWorkShiftRequest, UpdateWorkShiftRequest>("work-shifts")
export interface CreateHrDocumentTypeRequest {
code: string
name: string
category: HrDocumentType["category"]
requiredAtOnboarding: boolean
expiryTracked: boolean
}
export type UpdateHrDocumentTypeRequest = Omit<CreateHrDocumentTypeRequest, "code">
export const hrDocumentTypesApi = createMasterApi<HrDocumentType, CreateHrDocumentTypeRequest, UpdateHrDocumentTypeRequest>("hr-document-types")
export interface CreateLeaveTypeRequest {
code: string
name: string
isPaid: boolean
countsAsNoPay: boolean
accrualPerYear: number
carryForwardAllowed: boolean
maxCarryForwardDays?: number | null
requiresApproval: boolean
}
export type UpdateLeaveTypeRequest = Omit<CreateLeaveTypeRequest, "code">
export const leaveTypesApi = createMasterApi<LeaveType, CreateLeaveTypeRequest, UpdateLeaveTypeRequest>("leave-types")
export interface CreateSalaryComponentRequest {
code: string
name: string
componentType: SalaryComponent["componentType"]
isTaxable: boolean
isEpfEtfApplicable: boolean
}
export type UpdateSalaryComponentRequest = Omit<CreateSalaryComponentRequest, "code">
export const salaryComponentsApi = createMasterApi<SalaryComponent, CreateSalaryComponentRequest, UpdateSalaryComponentRequest>("salary-components")
+43
View File
@@ -0,0 +1,43 @@
// Leave requests (docs/13-BACKEND-HRM-API.md §5).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { LeaveRequest, LeaveRequestStatus } from "@/types/hrm"
export interface ListLeaveRequestsParams {
page?: number
pageSize?: number
employeeId?: number
status?: LeaveRequestStatus
}
export interface CreateLeaveRequestRequest {
employeeId: number
leaveTypeId: number
startDate: string
endDate: string
reason?: string | null
}
export const leaveRequestsApi = {
list(params: ListLeaveRequestsParams = {}): Promise<PagedResponse<LeaveRequest>> {
return apiRequest<PagedResponse<LeaveRequest>>(`/leave-requests${buildQuery(params)}`)
},
get(leaveRequestId: number): Promise<LeaveRequest> {
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}`)
},
create(request: CreateLeaveRequestRequest): Promise<LeaveRequest> {
return apiRequest<LeaveRequest>("/leave-requests", { method: "POST", body: request })
},
submit(leaveRequestId: number): Promise<LeaveRequest> {
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}/submit`, { method: "POST" })
},
approve(leaveRequestId: number): Promise<LeaveRequest> {
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}/approve`, { method: "POST" })
},
reject(leaveRequestId: number, reason: string): Promise<LeaveRequest> {
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}/reject`, { method: "POST", body: { reason } })
},
cancel(leaveRequestId: number): Promise<LeaveRequest> {
return apiRequest<LeaveRequest>(`/leave-requests/${leaveRequestId}/cancel`, { method: "POST" })
},
}
+70
View File
@@ -0,0 +1,70 @@
// Payroll runs, statutory settings, tax slabs, payslips (docs/13-BACKEND-HRM-API.md §6).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { Payslip, PayrollLine, PayrollLineDetail, PayrollRun, PayrollRunStatus, PayrollStatutorySetting, TaxSlab } from "@/types/hrm"
export interface ListPayrollRunsParams {
page?: number
pageSize?: number
periodYear?: number
periodMonth?: number
status?: PayrollRunStatus
}
export interface GeneratePayrollRunRequest {
periodYear: number
periodMonth: number
branchId?: number | null
}
export const payrollRunsApi = {
list(params: ListPayrollRunsParams = {}): Promise<PagedResponse<PayrollRun>> {
return apiRequest<PagedResponse<PayrollRun>>(`/payroll-runs${buildQuery(params)}`)
},
get(payrollRunId: number): Promise<PayrollRun> {
return apiRequest<PayrollRun>(`/payroll-runs/${payrollRunId}`)
},
listLines(payrollRunId: number): Promise<PayrollLine[]> {
return apiRequest<PayrollLine[]>(`/payroll-runs/${payrollRunId}/lines`)
},
getLine(payrollRunId: number, lineId: number): Promise<PayrollLineDetail> {
return apiRequest<PayrollLineDetail>(`/payroll-runs/${payrollRunId}/lines/${lineId}`)
},
generate(request: GeneratePayrollRunRequest): Promise<PayrollRun> {
return apiRequest<PayrollRun>("/payroll-runs", { method: "POST", body: request })
},
approve(payrollRunId: number): Promise<PayrollRun> {
return apiRequest<PayrollRun>(`/payroll-runs/${payrollRunId}/approve`, { method: "POST" })
},
lock(payrollRunId: number): Promise<PayrollRun> {
return apiRequest<PayrollRun>(`/payroll-runs/${payrollRunId}/lock`, { method: "POST" })
},
unlock(payrollRunId: number, reason: string): Promise<PayrollRun> {
return apiRequest<PayrollRun>(`/payroll-runs/${payrollRunId}/unlock`, { method: "POST", body: { reason } })
},
generatePayslips(payrollRunId: number): Promise<Payslip[]> {
return apiRequest<Payslip[]>(`/payroll-runs/${payrollRunId}/generate-payslips`, { method: "POST" })
},
}
export const payrollStatutorySettingsApi = {
list(): Promise<PayrollStatutorySetting[]> {
return apiRequest<PayrollStatutorySetting[]>("/payroll-statutory-settings")
},
create(request: Omit<PayrollStatutorySetting, "payrollStatutorySettingId" | "effectiveTo">): Promise<PayrollStatutorySetting> {
return apiRequest<PayrollStatutorySetting>("/payroll-statutory-settings", { method: "POST", body: request })
},
}
export const taxSlabsApi = {
list(): Promise<TaxSlab[]> {
return apiRequest<TaxSlab[]>("/tax-slabs")
},
create(request: Omit<TaxSlab, "taxSlabId" | "effectiveTo">): Promise<TaxSlab> {
return apiRequest<TaxSlab>("/tax-slabs", { method: "POST", body: request })
},
}
export function payslipViewUrl(payslipId: number): string {
return `/api/v1/payslips/${payslipId}/view`
}
+13
View File
@@ -28,6 +28,19 @@ const CODE_MESSAGES: Record<string, string> = {
CONCURRENCY_CONFLICT: "This record was changed by someone else. Reload and try again.",
PRECONDITION_REQUIRED: "This record needs to be reloaded before it can be updated.",
IDEMPOTENCY_REPLAY: "This request was already processed; showing the original result.",
EMPLOYEE_CODE_DUPLICATE: "An employee with that code already exists.",
EMPLOYEE_ALREADY_LINKED: "This staff record already has a linked system user.",
USER_ALREADY_LINKED: "This user account is already linked to a different staff record.",
DEPARTMENT_CYCLE_DETECTED: "Setting this parent would create a department cycle.",
DOCUMENT_TYPE_IN_USE: "This document type is referenced by existing documents and cannot be removed.",
FILE_TYPE_NOT_ALLOWED: "That file type isn't allowed. Use PDF, JPG, PNG, or DOCX.",
FILE_TOO_LARGE: "That file is too large.",
ATTENDANCE_BATCH_LOCKED: "This attendance batch is locked and cannot be edited.",
ATTENDANCE_DUPLICATE_UNRESOLVED: "Some records have unresolved errors or duplicates.",
ATTENDANCE_NOT_CONFIRMED: "Attendance for this period must be Confirmed before payroll can be generated.",
SALARY_STRUCTURE_OVERLAP: "The new effective date must be after the current salary structure's effective date.",
TAX_SLAB_GAP_INVALID: "This tax slab overlaps another slab for the same effective date.",
PAYROLL_PERIOD_LOCKED: "This payroll run is locked.",
validation_error: "Please check the highlighted fields.",
not_found: "The requested record was not found.",
conflict: "This action conflicts with the record's current state.",