feat: add payroll, time clocks, and leave request modules

- Implemented PayrollPage for managing payroll runs with CRUD operations.
- Created TimeClocksPage for tracking employee clock-ins and clock-outs.
- Developed TimeSheetsPage for displaying employee timesheets.
- Added EmployeeCertificationForm and EmployeeDocumentForm for managing employee certifications and documents.
- Introduced LeaveRequestForm for submitting and managing leave requests.
- Added usePermissions hook for UI-side permission checks.
- Created LeaveRequestsClient and PayrollClient for API interactions.
This commit is contained in:
2026-05-21 13:30:06 +04:00
parent 0d591b9807
commit 3c55be5052
29 changed files with 3808 additions and 113 deletions
+4
View File
@@ -59,6 +59,8 @@ import { ExpenseItemsClient } from "./clients/expense-items"
import { InventoryCategoriesClient } from "./clients/inventory-categories"
import { DocumentPrintClient } from "./clients/document-print"
import { DocumentShareClient } from "./clients/document-share"
import { LeaveRequestsClient } from "./clients/leave-requests"
import { PayrollClient } from "./clients/payroll"
export function createApi(options?: ApiClientOptions) {
return {
@@ -122,6 +124,8 @@ export function createApi(options?: ApiClientOptions) {
inventoryCategories: new InventoryCategoriesClient(undefined, options),
documentPrint: new DocumentPrintClient(undefined, options),
documentShare: new DocumentShareClient(undefined, options),
leaveRequests: new LeaveRequestsClient(undefined, options),
payroll: new PayrollClient(undefined, options),
}
}
+140 -1
View File
@@ -1,6 +1,7 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const EMPLOYEE_ROUTES = {
INDEX: "/api/employees",
@@ -8,12 +9,36 @@ export const EMPLOYEE_ROUTES = {
UPDATE_PERMISSIONS: "/api/employees/{id}/update-permissions",
} as const satisfies Record<string, ApiPath>
// Routes added after the last OpenAPI regen — not yet in the generated `paths`
// type, so callers go through `as never` casts.
export const EMPLOYEE_EXTRA_ROUTES = {
UPLOAD_AVATAR: "/api/employees/{id}/upload-avatar",
PERFORMANCE: "/api/employees/{id}/performance",
IMPORT: "/api/employees/import",
EXPORT: "/api/employees/export",
IMPORT_SAMPLE: "/api/employees/import/sample",
DOCUMENTS: "/api/employees/{employee}/documents",
DOCUMENT_BY_ID: "/api/employees/{employee}/documents/{id}",
CERTIFICATIONS: "/api/employees/{employee}/certifications",
CERTIFICATION_BY_ID: "/api/employees/{employee}/certifications/{id}",
LEAVE_BALANCE: "/api/employees/{employee}/leave-balance",
PAYROLL_SLIPS: "/api/employees/{employee}/payroll-slips",
} as const
export class EmployeesClient extends CrudClient<
typeof EMPLOYEE_ROUTES.INDEX,
typeof EMPLOYEE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, EMPLOYEE_ROUTES.INDEX, EMPLOYEE_ROUTES.BY_ID)
super(
baseUrl,
defaultOptions,
EMPLOYEE_ROUTES.INDEX,
EMPLOYEE_ROUTES.BY_ID,
EMPLOYEE_EXTRA_ROUTES.IMPORT as never,
EMPLOYEE_EXTRA_ROUTES.EXPORT as never,
"GET",
)
}
async getById(id: string) {
@@ -23,4 +48,118 @@ export class EmployeesClient extends CrudClient<
async updatePermissions(id: string, payload: ApiRequestBody<typeof EMPLOYEE_ROUTES.UPDATE_PERMISSIONS, "post">) {
return this.post(EMPLOYEE_ROUTES.UPDATE_PERMISSIONS, payload as never, { params: { id } } as never)
}
async uploadAvatar(id: string, file: File) {
const fd = new FormData()
fd.append("avatar", file)
return this.postFormData(`/api/employees/${id}/upload-avatar`, fd)
}
async downloadImportSample() {
return this.fetchBlob(EMPLOYEE_EXTRA_ROUTES.IMPORT_SAMPLE as never, { method: "GET" })
}
async performance(id: string, query?: { from?: string; to?: string }) {
return this.get(EMPLOYEE_EXTRA_ROUTES.PERFORMANCE as never, { params: { id }, query } as never)
}
// ── Documents ────────────────────────────────────────────────────
async listDocuments(employeeId: string, query?: ApiListQueryParams) {
return this.get(EMPLOYEE_EXTRA_ROUTES.DOCUMENTS as never, { params: { employee: employeeId }, query } as never)
}
async uploadDocument(employeeId: string, payload: {
file: File
name: string
type: string
issued_date?: string
expiry_date?: string
notes?: string
}) {
const fd = new FormData()
fd.append("file", payload.file)
fd.append("name", payload.name)
fd.append("type", payload.type)
if (payload.issued_date) fd.append("issued_date", payload.issued_date)
if (payload.expiry_date) fd.append("expiry_date", payload.expiry_date)
if (payload.notes) fd.append("notes", payload.notes)
return this.postFormData(`/api/employees/${employeeId}/documents`, fd)
}
async updateDocument(employeeId: string, id: string, payload: Record<string, unknown>) {
return this.put(EMPLOYEE_EXTRA_ROUTES.DOCUMENT_BY_ID as never, payload as never, { params: { employee: employeeId, id } } as never)
}
async destroyDocument(employeeId: string, id: string) {
return this.delete(EMPLOYEE_EXTRA_ROUTES.DOCUMENT_BY_ID as never, { params: { employee: employeeId, id } } as never)
}
// ── Certifications ───────────────────────────────────────────────
async listCertifications(employeeId: string, query?: ApiListQueryParams) {
return this.get(EMPLOYEE_EXTRA_ROUTES.CERTIFICATIONS as never, { params: { employee: employeeId }, query } as never)
}
async createCertification(employeeId: string, payload: {
name: string
issuer?: string
certificate_number?: string
issued_date?: string
expiry_date?: string
notes?: string
file?: File
}) {
const fd = new FormData()
fd.append("name", payload.name)
if (payload.issuer) fd.append("issuer", payload.issuer)
if (payload.certificate_number) fd.append("certificate_number", payload.certificate_number)
if (payload.issued_date) fd.append("issued_date", payload.issued_date)
if (payload.expiry_date) fd.append("expiry_date", payload.expiry_date)
if (payload.notes) fd.append("notes", payload.notes)
if (payload.file) fd.append("file", payload.file)
return this.postFormData(`/api/employees/${employeeId}/certifications`, fd)
}
async updateCertification(employeeId: string, id: string, payload: {
name?: string
issuer?: string
certificate_number?: string
issued_date?: string
expiry_date?: string
notes?: string
file?: File
}) {
const fd = new FormData()
if (payload.name) fd.append("name", payload.name)
if (payload.issuer) fd.append("issuer", payload.issuer)
if (payload.certificate_number) fd.append("certificate_number", payload.certificate_number)
if (payload.issued_date) fd.append("issued_date", payload.issued_date)
if (payload.expiry_date) fd.append("expiry_date", payload.expiry_date)
if (payload.notes) fd.append("notes", payload.notes)
if (payload.file) fd.append("file", payload.file)
return this.postFormData(`/api/employees/${employeeId}/certifications/${id}`, fd)
}
async destroyCertification(employeeId: string, id: string) {
return this.delete(EMPLOYEE_EXTRA_ROUTES.CERTIFICATION_BY_ID as never, { params: { employee: employeeId, id } } as never)
}
// ── Leave balance ────────────────────────────────────────────────
async getLeaveBalance(employeeId: string, year?: number) {
return this.get(EMPLOYEE_EXTRA_ROUTES.LEAVE_BALANCE as never, {
params: { employee: employeeId },
query: year ? { year } : undefined,
} as never)
}
async updateLeaveBalance(employeeId: string, payload: { year: number; annual_total?: number; sick_total?: number }) {
return this.put(EMPLOYEE_EXTRA_ROUTES.LEAVE_BALANCE as never, payload as never, { params: { employee: employeeId } } as never)
}
// ── Payroll slips for an employee ────────────────────────────────
async listPayrollSlips(employeeId: string, query?: ApiListQueryParams & { year?: number }) {
return this.get(EMPLOYEE_EXTRA_ROUTES.PAYROLL_SLIPS as never, {
params: { employee: employeeId },
query,
} as never)
}
}
+2
View File
@@ -83,3 +83,5 @@ export { ConfigurationsClient, CONFIGURATION_ROUTES } from "./configurations"
export { AutoGenerateClient, AUTO_GENERATE_ROUTES } from "./auto-generate"
export { ExpenseItemsClient, EXPENSE_ITEM_ROUTES } from "./expense-items"
export { InventoryCategoriesClient, INVENTORY_CATEGORY_ROUTES } from "./inventory-categories"
export { LeaveRequestsClient, LEAVE_REQUEST_ROUTES } from "./leave-requests"
export { PayrollClient, PAYROLL_ROUTES } from "./payroll"
@@ -0,0 +1,50 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiListQueryParams } from "../contracts/types"
// New endpoints — not yet in the OpenAPI spec; routes are raw strings cast as needed.
export const LEAVE_REQUEST_ROUTES = {
INDEX: "/api/leave-requests",
BY_ID: "/api/leave-requests/{id}",
APPROVE: "/api/leave-requests/{id}/approve",
REJECT: "/api/leave-requests/{id}/reject",
} as const
export type LeaveStatus = "pending" | "approved" | "rejected" | "cancelled"
export class LeaveRequestsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams & { employee_id?: string | number; status?: LeaveStatus; leave_type?: string; year?: number }) {
return this.get(LEAVE_REQUEST_ROUTES.INDEX as never, { query } as never)
}
async show(id: string) {
return this.get(LEAVE_REQUEST_ROUTES.BY_ID as never, { params: { id } } as never)
}
async getById(id: string) {
return this.show(id)
}
async create(payload: { employee_id: number | string; leave_type: string; start_date: string; end_date: string; reason?: string }) {
return this.post(LEAVE_REQUEST_ROUTES.INDEX as never, payload as never)
}
async update(id: string, payload: { leave_type?: string; start_date?: string; end_date?: string; reason?: string }) {
return this.put(LEAVE_REQUEST_ROUTES.BY_ID as never, payload as never, { params: { id } } as never)
}
async destroy(id: string) {
return this.delete(LEAVE_REQUEST_ROUTES.BY_ID as never, { params: { id } } as never)
}
async approve(id: string, payload?: { decision_note?: string; approved_by?: number }) {
return this.post(LEAVE_REQUEST_ROUTES.APPROVE as never, (payload ?? {}) as never, { params: { id } } as never)
}
async reject(id: string, payload?: { decision_note?: string; approved_by?: number }) {
return this.post(LEAVE_REQUEST_ROUTES.REJECT as never, (payload ?? {}) as never, { params: { id } } as never)
}
}
+63
View File
@@ -0,0 +1,63 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiListQueryParams } from "../contracts/types"
export const PAYROLL_ROUTES = {
RUNS_INDEX: "/api/payroll/runs",
RUN_BY_ID: "/api/payroll/runs/{id}",
REGENERATE: "/api/payroll/runs/{id}/regenerate",
FINALIZE: "/api/payroll/runs/{id}/finalize",
MARK_PAID: "/api/payroll/runs/{id}/mark-paid",
ENTRY_BY_ID: "/api/payroll/runs/{run}/entries/{entry}",
} as const
export type PayrollStatus = "draft" | "finalized" | "paid"
export class PayrollClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams & { status?: PayrollStatus; year?: number }) {
return this.get(PAYROLL_ROUTES.RUNS_INDEX as never, { query } as never)
}
async show(id: string) {
return this.get(PAYROLL_ROUTES.RUN_BY_ID as never, { params: { id } } as never)
}
async getById(id: string) {
return this.show(id)
}
async create(payload: { period_start: string; period_end: string; reference?: string; note?: string }) {
return this.post(PAYROLL_ROUTES.RUNS_INDEX as never, payload as never)
}
async destroy(id: string) {
return this.delete(PAYROLL_ROUTES.RUN_BY_ID as never, { params: { id } } as never)
}
async regenerate(id: string) {
return this.post(PAYROLL_ROUTES.REGENERATE as never, {} as never, { params: { id } } as never)
}
async finalize(id: string) {
return this.post(PAYROLL_ROUTES.FINALIZE as never, {} as never, { params: { id } } as never)
}
async markPaid(id: string) {
return this.post(PAYROLL_ROUTES.MARK_PAID as never, {} as never, { params: { id } } as never)
}
async updateEntry(runId: string, entryId: string, payload: {
hours_worked?: number
overtime_hours?: number
base_salary?: number
commission_amount?: number
allowances?: number
deductions?: number
note?: string
}) {
return this.put(PAYROLL_ROUTES.ENTRY_BY_ID as never, payload as never, { params: { run: runId, entry: entryId } } as never)
}
}