Membangun Alur Persetujuan Cuti HR di Custom ERP

Foto oleh Unsplash

Foto oleh Unsplash
Salah satu modul pertama yang saya bangun di ERP custom Commsult Indonesia adalah sistem manajemen cuti HR. Di permukaan terdengar sederhana: karyawan mengajukan permintaan cuti, manajer menyetujui atau menolaknya, dan sistem memperbarui saldo cuti. Dalam praktiknya, mendapatkan transaksi database yang benar, menangani percobaan persetujuan bersamaan, dan menerapkan kuota cuti secara atomik membutuhkan pemikiran yang jauh lebih matang.
Persyaratan HR Commsult tidak cocok dengan modul siap pakai manapun. Jenis cuti, hierarki persetujuan, dan aturan kuota spesifik untuk hukum ketenagakerjaan Indonesia dan kebijakan perusahaan. Mengadaptasi modul yang ada seperti HR Odoo sebenarnya lebih lama daripada membangun sendiri.
Schema memiliki tiga tabel inti: employees (sudah ada), leave_requests (satu baris per permintaan, dengan enum status: PENDING/APPROVED/REJECTED/CANCELLED), dan leave_quotas (satu baris per karyawan per tahun per jenis cuti). days_count di leave_requests adalah generated column yang dihitung dari start_date dan end_date.
Status permintaan cuti adalah finite state machine dengan empat state. PENDING adalah state awal. Manajer dapat bertransisi ke APPROVED atau REJECTED. Karyawan dapat bertransisi dari PENDING ke CANCELLED. Tidak ada transisi lain yang valid. Mencoba menyetujui permintaan yang sudah ditolak mengembalikan error 400.
HR Leave Approval Flow
──────────────────────
Employee submits leave request
│
▼
┌───────────────────────┐
│ leave_requests table │
│ status: PENDING │
│ approver_id: <mgr> │
└───────────┬───────────┘
│ email notification sent
▼
┌───────────────────────┐
│ Manager Review │◀─── Manager opens approval link
│ (configurable SLA) │
└──────┬────────┬───────┘
│ │
APPROVE REJECT
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ APPROVED │ │ REJECTED │
│ status │ │ status │
└────┬─────┘ └────┬─────┘
│ │
▼ ▼
Update leave Notify employee
balance with reason
(quota table)
│
▼
Sync attendance
calendar viewGunakan kolom GENERATED ALWAYS AS PostgreSQL untuk nilai terhitung seperti days_count. Ini memindahkan kalkulasi ke database di mana ia tidak bisa divergen dari tanggal yang tersimpan, dan tersedia dalam query tanpa komputasi di lapisan aplikasi.
Bagian tersulitnya adalah logika persetujuan: ketika manajer menyetujui permintaan, kita harus secara bersamaan memeriksa kuota, menambah used_days, dan memperbarui status ke APPROVED—semua secara atomik. Solusinya adalah transaksi database dengan row-level lock pada baris kuota.
Transaksi TypeORM dengan pessimistic write lock pada baris kuota mencegah concurrent approval berlomba pada pengurangan kuota. Transaksi membaca baris kuota dengan FOR UPDATE, memeriksa saldo tersisa, menambah used_days, dan commit—semuanya sebagai satu unit atomik.
-- PostgreSQL schema for leave management
CREATE TYPE leave_status AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED');
CREATE TYPE leave_type AS ENUM ('ANNUAL', 'SICK', 'UNPAID', 'MATERNITY', 'PATERNITY');
CREATE TABLE leave_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
employee_id UUID NOT NULL REFERENCES employees(id),
approver_id UUID NOT NULL REFERENCES employees(id),
leave_type leave_type NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
days_count INTEGER GENERATED ALWAYS AS (end_date - start_date + 1) STORED,
reason TEXT,
status leave_status NOT NULL DEFAULT 'PENDING',
rejection_reason TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Leave quota per employee per year
CREATE TABLE leave_quotas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
employee_id UUID NOT NULL REFERENCES employees(id),
year INTEGER NOT NULL,
leave_type leave_type NOT NULL,
total_days INTEGER NOT NULL,
used_days INTEGER NOT NULL DEFAULT 0,
UNIQUE (employee_id, year, leave_type)
);
-- NestJS service: approve leave
// leave.service.ts
@Injectable()
export class LeaveService {
constructor(
@InjectRepository(LeaveRequest)
private leaveRepo: Repository<LeaveRequest>,
@InjectRepository(LeaveQuota)
private quotaRepo: Repository<LeaveQuota>,
private emailService: EmailService,
private dataSource: DataSource,
) {}
async approveLeave(requestId: string, approverId: string): Promise<LeaveRequest> {
return this.dataSource.transaction(async (manager) => {
const request = await manager.findOneOrFail(LeaveRequest, {
where: { id: requestId, approverId, status: LeaveStatus.PENDING },
relations: ['employee'],
});
// Check quota
const quota = await manager.findOneOrFail(LeaveQuota, {
where: {
employeeId: request.employeeId,
year: new Date(request.startDate).getFullYear(),
leaveType: request.leaveType,
},
});
const remaining = quota.totalDays - quota.usedDays;
if (request.daysCount > remaining) {
throw new BadRequestException(
`Insufficient leave balance: ${remaining} days remaining`
);
}
// Update quota and request atomically
await manager.increment(
LeaveQuota,
{ id: quota.id },
'usedDays',
request.daysCount,
);
request.status = LeaveStatus.APPROVED;
request.approvedAt = new Date();
const saved = await manager.save(request);
await this.emailService.sendLeaveApproved(request.employee.email, request);
return saved;
});
}
}Notifikasi email dikirim di dua titik: saat permintaan diajukan (ke manajer) dan saat manajer bertindak (ke karyawan). Email diantrekan dalam queue BullMQ sehingga kegagalan secara otomatis dicoba ulang tanpa memblokir respons API.
Jika Anda pernah memperbarui kuota cuti atau status permintaan langsung di database saat debugging, jalankan query rekonsiliasi yang membandingkan jumlah hari cuti yang disetujui dengan kolom used_days. Drift di sini tidak terlihat dan menyebabkan tampilan saldo yang salah.
Antarmuka persetujuan manajer adalah tampilan inbox sederhana yang dibuat di React. Ini melakukan polling ke API setiap 30 detik untuk permintaan yang tertunda, menampilkannya dalam tabel dengan nama karyawan, jenis cuti, tanggal, dan sisa kuota.
Kolom generated days_count menyelamatkan kami dari setidaknya tiga bug integritas data. Row-level lock mencegah masalah concurrent-request nyata. Kesalahan terbesar adalah awalnya mengirim email di dalam transaksi database—memindahkan ke BullMQ membuat persetujuan terasa instan.