Skip to main content
BlogERP

Membangun Alur Persetujuan Cuti HR di Custom ERP

Cara saya merancang dan membangun sistem persetujuan cuti transaksional dari nol menggunakan NestJS, PostgreSQL, dan TypeORM—tanpa Odoo atau platform HR siap pakai.

Matthews Wong

Software Engineer

Juni 20258 menit baca
Membangun Alur Persetujuan Cuti HR di Custom ERP

Foto oleh Unsplash

Ringkasan Utama

Modul cuti HR di ERP custom lebih sulit dari kelihatannya karena saldo cuti dan pengajuannya harus berubah di dalam satu transaksi database. Hukum ketenagakerjaan Indonesia menambah jenis cuti, aturan akrual kuota dan hierarki persetujuan yang jarang cocok dengan modul HR siap pakai, dan itulah yang biasanya memaksa dibangunnya sistem custom.

Salah satu modul pertama yang saya bangun dalam pekerjaan konsultasi ERP freelance saya adalah sistem manajemen cuti HR untuk ERP custom klien. 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.

Kenapa Membangun dari Nol?

Persyaratan HR klien ini tidak cocok dengan modul siap pakai manapun. Jenis cuti, hierarki persetujuan, dan aturan kuota spesifik untuk hukum ketenagakerjaan Indonesia dan kebijakan perusahaan. Dalam pekerjaan konsultasi ERP freelance saya, mengadaptasi modul yang ada seperti HR Odoo sebenarnya lebih lama daripada membangun sendiri.

Schema Database: Fondasi

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.

State Machine Persetujuan

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 view

Gunakan 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.

Transaksi Persetujuan yang Atomik

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.

Menangani Race Condition

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: Dipicu oleh Transisi Status

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.

Frontend React: Inbox Persetujuan

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.

Pelajaran Setelah Enam Bulan di Produksi

Kolom generated days_count menyelamatkan saya 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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa sistem persetujuan cuti dibangun dari nol alih-alih menggunakan modul siap pakai seperti HR Odoo?

Jenis cuti, hierarki persetujuan, dan aturan kuota sering kali spesifik untuk hukum ketenagakerjaan Indonesia dan kebijakan perusahaan, sehingga mungkin tidak cocok dengan modul yang ada. Mengadaptasi HR Odoo bisa memerlukan pemahaman internalsnya secara mendalam, sehingga pengembangan kustom justru bisa lebih cepat. Membangun sendiri juga memungkinkan integrasi alami dengan codebase NestJS dan PostgreSQL yang sudah ada.

Bagaimana sistem mencegah dua manajer menyetujui cuti secara bersamaan hingga melampaui kuota karyawan?

Row-level lock PostgreSQL (FOR UPDATE) dipasang pada baris kuota saat transaksi persetujuan dimulai, sehingga transaksi lain yang berjalan bersamaan harus menunggu hingga transaksi pertama selesai sebelum bisa membaca kuota yang sudah diperbarui. Ketiga operasi—pengecekan kuota, penambahan used_days, dan pembaruan status—dijalankan sebagai satu unit atomik untuk mencegah overspend hari cuti.

Mengapa notifikasi email dikirim di luar transaksi database, bukan di dalamnya?

Awalnya email dikirim di dalam transaksi database, dan latensi pengiriman email menambah jeda yang terasa pada respons API. Memindahkan pengiriman email ke queue BullMQ (berbasis Redis) setelah transaksi berhasil commit memastikan kegagalan dicoba ulang secara otomatis tanpa memblokir API, sehingga proses persetujuan terasa instan bagi pengguna.

Apa manfaat menggunakan generated column PostgreSQL untuk days_count pada permintaan cuti?

Kolom days_count dihitung langsung oleh database dari start_date dan end_date menggunakan GENERATED ALWAYS AS, sehingga nilainya tidak bisa divergen akibat bug di lapisan aplikasi. Kolom ini juga langsung tersedia dalam query SQL tanpa komputasi tambahan, yang sangat berguna untuk laporan penggunaan cuti di level departemen.

Apa yang menyebabkan drift kuota cuti, dan bagaimana cara mendeteksinya?

Drift kuota terjadi ketika kuota cuti atau status permintaan diperbarui langsung di database di luar service method—misalnya saat debugging atau migrasi data. Drift ini tidak terlihat dan menyebabkan tampilan saldo yang salah sehingga menurunkan kepercayaan pengguna. Cara mendeteksinya adalah dengan menjalankan query rekonsiliasi yang membandingkan total hari cuti yang disetujui dengan kolom used_days.