Skema dari Bagian 1 hanyalah kerangka; denyut aplikasi manajemen tugas dimulai saat anggota tim baru bergabung dan tugas baru dibuat. Pada bagian ini kita mengisi tugas_db dengan data awal, lalu melakukan pekerjaan sehari-hari papan tugas: mendaftarkan pengguna baru, membuat tugas untuknya, mengalihkan tugas yang belum diambil, dan membersihkan tugas yang keliru dibuat. Sepanjang jalan, tiga jenis constraint — UNIQUE, foreign key, dan ENUM — akan berkesempatan menunjukkan cara kerjanya menolak data yang salah bentuk.

Membangun Ulang Kondisi Awal #

Setiap bagian proyek ini mandiri, jadi blok pertama kita selalu membangun ulang tugas_db dari nol: skema dari berkas database/skema.sql diikuti seluruh isi database/data-awal.sql:

sql
DROP DATABASE IF EXISTS tugas_db;
CREATE DATABASE tugas_db;
USE tugas_db;

CREATE TABLE pengguna (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  email VARCHAR(60) NOT NULL UNIQUE
);

CREATE TABLE proyek (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  pemilik_id INT NOT NULL,
  CONSTRAINT fk_proyek_pemilik
    FOREIGN KEY (pemilik_id) REFERENCES pengguna (id)
);

CREATE TABLE tugas (
  id INT AUTO_INCREMENT PRIMARY KEY,
  judul VARCHAR(80) NOT NULL,
  proyek_id INT NOT NULL,
  assignee_id INT NULL,
  prioritas ENUM('rendah', 'sedang', 'tinggi') NOT NULL,
  tenggat DATE NULL,
  status ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  CONSTRAINT fk_tugas_proyek
    FOREIGN KEY (proyek_id) REFERENCES proyek (id),
  CONSTRAINT fk_tugas_assignee
    FOREIGN KEY (assignee_id) REFERENCES pengguna (id)
);

CREATE TABLE riwayat_status (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tugas_id INT NOT NULL,
  status_lama ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  status_baru ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  waktu DATETIME NOT NULL,
  catatan VARCHAR(200),
  CONSTRAINT fk_riwayat_tugas
    FOREIGN KEY (tugas_id) REFERENCES tugas (id)
);

INSERT INTO pengguna (id, nama, email) VALUES
  (1, 'Budi Santoso', '[email protected]'),
  (2, 'Siti Rahma', '[email protected]'),
  (3, 'Andi Wijaya', '[email protected]'),
  (4, 'Dewi Lestari', '[email protected]'),
  (5, 'Tono Pratama', '[email protected]'),
  (6, 'Rina Kusuma', '[email protected]');

INSERT INTO proyek (id, nama, pemilik_id) VALUES
  (1, 'Rilis Aplikasi Mobile', 1),
  (2, 'Migrasi Data Gudang', 2),
  (3, 'Kampanye Pemasaran Q1', 4);

INSERT INTO tugas (id, judul, proyek_id, assignee_id, prioritas, tenggat, status) VALUES
  (1,  'Desain mockup halaman utama',  1, 3,    'tinggi', '2026-02-10', 'selesai'),
  (2,  'Implementasi API autentikasi', 1, 1,    'tinggi', '2026-02-10', 'berjalan'),
  (3,  'Uji coba beta di Play Store',  1, NULL, 'sedang', '2026-03-15', 'baru'),
  (4,  'Pemetaan tabel lama ke baru',  2, 2,    'tinggi', '2026-02-05', 'selesai'),
  (5,  'Skrip migrasi batch pertama', 2, 5,    'sedang', '2026-02-25', 'berjalan'),
  (6,  'Validasi hasil migrasi',      2, 4,    'rendah', '2026-03-01', 'baru'),
  (7,  'Menyusun materi iklan',       3, 4,    'sedang', '2026-02-12', 'selesai'),
  (8,  'Riset kata kunci iklan',      3, 3,    'rendah', '2026-02-08', 'selesai'),
  (9,  'Negosiasi anggaran media',    3, 1,    'tinggi', '2026-02-12', 'berjalan'),
  (10, 'Laporan performa kampanye',   3, NULL, 'sedang', '2026-04-10', 'baru');

INSERT INTO riwayat_status (id, tugas_id, status_lama, status_baru, waktu, catatan) VALUES
  (1,  1, 'baru',     'berjalan', '2026-01-06 10:15:00', 'Mulai kerjakan mockup'),
  (2,  1, 'berjalan', 'selesai',  '2026-02-09 16:40:00', 'Mockup disetujui tim'),
  (3,  2, 'baru',     'berjalan', '2026-01-20 11:00:00', 'Sprint 1 dimulai'),
  (4,  4, 'baru',     'berjalan', '2026-01-07 09:00:00', 'Pemetaan awal berjalan'),
  (5,  4, 'berjalan', 'selesai',  '2026-02-04 17:00:00', 'Pemetaan tervalidasi'),
  (6,  5, 'baru',     'berjalan', '2026-01-15 10:30:00', 'Skrip versi awal mulai dikerjakan'),
  (7,  7, 'baru',     'berjalan', '2026-01-12 10:00:00', 'Brief iklan diterima'),
  (8,  7, 'berjalan', 'selesai',  '2026-02-11 15:30:00', 'Materi disetujui manajemen'),
  (9,  8, 'baru',     'berjalan', '2026-01-13 09:10:00', 'Riset kata kunci dimulai'),
  (10, 8, 'berjalan', 'selesai',  '2026-02-06 12:00:00', 'Riset selesai, 40 kata kunci'),
  (11, 9, 'baru',     'berjalan', '2026-01-25 10:30:00', 'Rapat awal dengan vendor');

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 6 rows affected (0.00 sec)
Records: 6  Duplicates: 0  Warnings: 0

Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

Query OK, 10 rows affected (0.00 sec)
Records: 10  Duplicates: 0  Warnings: 0

Query OK, 11 rows affected (0.00 sec)
Records: 11  Duplicates: 0  Warnings: 0

Menambah Pengguna Baru #

Tim kedatangan anggota baru: Fajar Nugroho. Kita tidak menyebut kolom id — biarkan AUTO_INCREMENT melanjutkan dari enam baris awal sehingga Fajar menjadi pengguna ketujuh:

sql
INSERT INTO pengguna (nama, email)
VALUES ('Fajar Nugroho', '[email protected]');

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Kolom yang tidak disebut pada daftar INSERT mengisi nilai defaultnya; untuk id artinya angka berikutnya dari penghitung AUTO_INCREMENT, yaitu 7. Email yang sama persis tidak akan lolos dua kali — coba daftarkan pengguna lain memakai email milik Budi:

sql
-- @error
INSERT INTO pengguna (nama, email) VALUES ('Budi Kedua', '[email protected]');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1062 (23000): Duplicate entry '[email protected]' for key 'pengguna.email'
Bye

Pesan galat menyebut pengguna.email — nama index unik yang dibuat kolom UNIQUE — lengkap dengan nilai yang bentrok. Dua akun untuk satu orang adalah sumber kekacauan klasik aplikasi tim, dan database menolaknya di muka pintu.

Membuat Tugas untuk Pengguna Baru #

Fajar langsung mendapat tugas pertamanya di proyek Rilis Aplikasi Mobile. assignee_id kita isi 7 — nomor id Fajar yang barusan terbit:

sql
INSERT INTO tugas (judul, proyek_id, assignee_id, prioritas, tenggat, status)
VALUES ('Dokumentasi API publik', 1, 7, 'sedang', '2026-03-20', 'baru');

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Foreign key kini ikut bekerja. Coba buat tugas yang menunjuk proyek bernomor 99 — nomor yang tidak ada di tabel proyek:

sql
-- @error
INSERT INTO tugas (judul, proyek_id, assignee_id, prioritas, tenggat, status) VALUES ('Tugas siluman', 99, NULL, 'sedang', '2026-03-01', 'baru');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`tugas_db`.`tugas`, CONSTRAINT `fk_tugas_proyek` FOREIGN KEY (`proyek_id`) REFERENCES `proyek` (`id`))
Bye

Galat 1452 muncul dari constraint fk_tugas_proyek: baris anak tidak boleh menunjuk induk yang tidak ada. Pesan lengkapnya menyebut nama constraint, tabel, dan kolom yang gagal divalidasi — keuntungan menamai foreign key secara eksplisit sejak Bagian 1. Perhatikan pula bahwa assignee_id pada percobaan tadi dibiarkan NULL dan tidak digugat: nilai NULL tidak pernah diperiksa foreign key, sehingga tugas tanpa pengerja tetap sah.

Mengalihkan Penanggung Jawab #

Tugas 3 (uji coba beta) masih NULL penanggungnya. Rapat mingguan memutuskan Dewi Lestari yang mengambil alih — satu UPDATE mengubah kolom tersebut:

sql
UPDATE tugas SET assignee_id = 4
WHERE id = 3;

SELECT
  t.id,
  t.judul,
  u.nama AS penanggung_jawab,
  t.status
FROM tugas t
LEFT JOIN pengguna u ON u.id = t.assignee_id
WHERE t.id = 3;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

+----+-----------------------------+------------------+--------+
| id | judul                       | penanggung_jawab | status |
+----+-----------------------------+------------------+--------+
|  3 | Uji coba beta di Play Store | Dewi Lestari     | baru   |
+----+-----------------------------+------------------+--------+
1 row in set (0.00 sec)

Kolom penanggung_jawab yang tadinya NULL kini terisi Dewi Lestari — bukti bahwa UPDATE mengubah satu baris yang dipilih WHERE id = 3. WHERE pada UPDATE wajib dipikirkan matang: tanpa klausa itu, seluruh baris tabel akan terubah sekaligus.

Menghapus Tugas — dan Ditolak Riwayatnya #

Tugas 11 keliru dibuat (dokumentasinya sudah ada di wiki tim), jadi kita hapus. Tetapi ada satu tugas yang tidak boleh terhapus: tugas 1 sudah punya dua baris riwayat, dan database wajib menjaga sejarahnya:

sql
-- @error
DELETE FROM tugas WHERE id = 1;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`tugas_db`.`riwayat_status`, CONSTRAINT `fk_riwayat_tugas` FOREIGN KEY (`tugas_id`) REFERENCES `tugas` (`id`))
Bye

Galat 1451 menjelaskan arah kebalikannya dari 1452: kita mencoba menghapus baris induk yang masih dirujuk baris anak di riwayat_status. Riwayat tanpa tugas induknya menjadi catatan yatim — tidak lagi bisa dipercaya. InnoDB memilih menolak penghapusan daripada membiarkan data setengah hidup. Tugas 11 aman dihapus karena belum punya riwayat apa pun:

sql
DELETE FROM tugas WHERE id = 11;

SELECT COUNT(*) AS total_tugas FROM tugas;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

+-------------+
| total_tugas |
+-------------+
|          10 |
+-------------+
1 row in set (0.00 sec)

Sepuluh tugas kembali — penghapusan tugas 11 hanya mengurangi satu, dan baris riwayat tidak tersentuh karena tugas itu memang belum pernah berpindah status.

Membersihkan Database Latihan #

Bagian ini selesai dan tugas_db boleh kita bongkar:

sql
DROP DATABASE IF EXISTS tugas_db;

Output yang Dihasilkan:

text
Query OK, 4 rows affected (0.00 sec)

Referensi #

CRUD dasar sudah berjalan dengan tiga lapis pengaman yang terbukti hidup. Bagian 3 memasuki jantung aplikasi: mengubah status tugas beserta pencatatannya dalam satu transaksi — dan melihat betapa berantakannya dunia bila keduanya dipisahkan.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

database/data-awal.sql

Unduh berkas
sql
-- data-awal.sql — Proyek: Database Manajemen Tugas
-- Data contoh deterministik (6 pengguna, 3 proyek, 10 tugas, riwayat awal).
-- Cara pakai: mysql -u root -p tugas_db < data-awal.sql
-- (jalankan skema.sql terlebih dahulu)

USE tugas_db;

INSERT INTO pengguna (id, nama, email) VALUES
  (1, 'Budi Santoso', '[email protected]'),
  (2, 'Siti Rahma', '[email protected]'),
  (3, 'Andi Wijaya', '[email protected]'),
  (4, 'Dewi Lestari', '[email protected]'),
  (5, 'Tono Pratama', '[email protected]'),
  (6, 'Rina Kusuma', '[email protected]');

INSERT INTO proyek (id, nama, pemilik_id) VALUES
  (1, 'Rilis Aplikasi Mobile', 1),
  (2, 'Migrasi Data Gudang', 2),
  (3, 'Kampanye Pemasaran Q1', 4);

INSERT INTO tugas (id, judul, proyek_id, assignee_id, prioritas, tenggat, status) VALUES
  (1,  'Desain mockup halaman utama',  1, 3,    'tinggi', '2026-02-10', 'selesai'),
  (2,  'Implementasi API autentikasi', 1, 1,    'tinggi', '2026-02-10', 'berjalan'),
  (3,  'Uji coba beta di Play Store',  1, NULL, 'sedang', '2026-03-15', 'baru'),
  (4,  'Pemetaan tabel lama ke baru',  2, 2,    'tinggi', '2026-02-05', 'selesai'),
  (5,  'Skrip migrasi batch pertama', 2, 5,    'sedang', '2026-02-25', 'berjalan'),
  (6,  'Validasi hasil migrasi',      2, 4,    'rendah', '2026-03-01', 'baru'),
  (7,  'Menyusun materi iklan',       3, 4,    'sedang', '2026-02-12', 'selesai'),
  (8,  'Riset kata kunci iklan',      3, 3,    'rendah', '2026-02-08', 'selesai'),
  (9,  'Negosiasi anggaran media',    3, 1,    'tinggi', '2026-02-12', 'berjalan'),
  (10, 'Laporan performa kampanye',   3, NULL, 'sedang', '2026-04-10', 'baru');

INSERT INTO riwayat_status (id, tugas_id, status_lama, status_baru, waktu, catatan) VALUES
  (1,  1, 'baru',     'berjalan', '2026-01-06 10:15:00', 'Mulai kerjakan mockup'),
  (2,  1, 'berjalan', 'selesai',  '2026-02-09 16:40:00', 'Mockup disetujui tim'),
  (3,  2, 'baru',     'berjalan', '2026-01-20 11:00:00', 'Sprint 1 dimulai'),
  (4,  4, 'baru',     'berjalan', '2026-01-07 09:00:00', 'Pemetaan awal berjalan'),
  (5,  4, 'berjalan', 'selesai',  '2026-02-04 17:00:00', 'Pemetaan tervalidasi'),
  (6,  5, 'baru',     'berjalan', '2026-01-15 10:30:00', 'Skrip versi awal mulai dikerjakan'),
  (7,  7, 'baru',     'berjalan', '2026-01-12 10:00:00', 'Brief iklan diterima'),
  (8,  7, 'berjalan', 'selesai',  '2026-02-11 15:30:00', 'Materi disetujui manajemen'),
  (9,  8, 'baru',     'berjalan', '2026-01-13 09:10:00', 'Riset kata kunci dimulai'),
  (10, 8, 'berjalan', 'selesai',  '2026-02-06 12:00:00', 'Riset selesai, 40 kata kunci'),
  (11, 9, 'baru',     'berjalan', '2026-01-25 10:30:00', 'Rapat awal dengan vendor');

database/skema.sql

Unduh berkas
sql
-- skema.sql — Proyek: Database Manajemen Tugas
-- Membangun database tugas_db beserta 4 tabel berelasi (pengguna, proyek,
-- tugas, riwayat_status) lengkap dengan foreign key bernama.
-- Cara pakai: mysql -u root -p < skema.sql

DROP DATABASE IF EXISTS tugas_db;
CREATE DATABASE tugas_db;
USE tugas_db;

CREATE TABLE pengguna (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  email VARCHAR(60) NOT NULL UNIQUE
);

CREATE TABLE proyek (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  pemilik_id INT NOT NULL,
  CONSTRAINT fk_proyek_pemilik
    FOREIGN KEY (pemilik_id) REFERENCES pengguna (id)
);

CREATE TABLE tugas (
  id INT AUTO_INCREMENT PRIMARY KEY,
  judul VARCHAR(80) NOT NULL,
  proyek_id INT NOT NULL,
  assignee_id INT NULL,
  prioritas ENUM('rendah', 'sedang', 'tinggi') NOT NULL,
  tenggat DATE NULL,
  status ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  CONSTRAINT fk_tugas_proyek
    FOREIGN KEY (proyek_id) REFERENCES proyek (id),
  CONSTRAINT fk_tugas_assignee
    FOREIGN KEY (assignee_id) REFERENCES pengguna (id)
);

CREATE TABLE riwayat_status (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tugas_id INT NOT NULL,
  status_lama ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  status_baru ENUM('baru', 'berjalan', 'selesai') NOT NULL,
  waktu DATETIME NOT NULL,
  catatan VARCHAR(200),
  CONSTRAINT fk_riwayat_tugas
    FOREIGN KEY (tugas_id) REFERENCES tugas (id)
);

queries/laporan-beban-kerja.sql

Unduh berkas
sql
-- laporan-beban-kerja.sql — Beban kerja tiap anggota (total/selesai/berjalan/baru).
-- Cara pakai: mysql -u root -p tugas_db < queries/laporan-beban-kerja.sql
USE tugas_db;

SELECT
  p.nama,
  COUNT(t.id) AS total_tugas,
  SUM(CASE WHEN t.status = 'selesai' THEN 1 ELSE 0 END) AS selesai,
  SUM(CASE WHEN t.status = 'berjalan' THEN 1 ELSE 0 END) AS berjalan,
  SUM(CASE WHEN t.status = 'baru' THEN 1 ELSE 0 END) AS baru
FROM pengguna p
LEFT JOIN tugas t ON t.assignee_id = p.id
GROUP BY p.id, p.nama
ORDER BY total_tugas DESC, p.nama;

queries/laporan-overdue.sql

Unduh berkas
sql
-- laporan-overdue.sql — Tugas menembus tenggat namun belum selesai.
-- Ganti tanggal acuan sesuai kebutuhan (di aplikasi: CURRENT_DATE()).
-- Cara pakai: mysql -u root -p tugas_db < queries/laporan-overdue.sql
USE tugas_db;

SELECT
  t.id,
  t.judul,
  pr.nama AS proyek,
  p.nama AS penanggung,
  t.tenggat,
  DATEDIFF('2026-02-15', t.tenggat) AS hari_terlambat
FROM tugas t
JOIN proyek pr ON pr.id = t.proyek_id
LEFT JOIN pengguna p ON p.id = t.assignee_id
WHERE t.status <> 'selesai'
  AND t.tenggat < '2026-02-15'
ORDER BY t.tenggat, t.id;

queries/laporan-status-proyek.sql

Unduh berkas
sql
-- laporan-status-proyek.sql — Distribusi status + persentase selesai per proyek.
-- Cara pakai: mysql -u root -p tugas_db < queries/laporan-status-proyek.sql
USE tugas_db;

SELECT
  pr.nama AS proyek,
  COUNT(t.id) AS total_tugas,
  SUM(CASE WHEN t.status = 'selesai' THEN 1 ELSE 0 END) AS selesai,
  ROUND(
    SUM(CASE WHEN t.status = 'selesai' THEN 1 ELSE 0 END) / COUNT(t.id) * 100,
    1
  ) AS persen_selesai
FROM proyek pr
LEFT JOIN tugas t ON t.proyek_id = pr.id
GROUP BY pr.id, pr.nama
ORDER BY persen_selesai DESC, pr.nama;

tests/pemeriksaan.sql

Unduh berkas
sql
-- pemeriksaan.sql — Proyek: Database Manajemen Tugas
-- Rangkaian pemeriksaan otomatis: skema, jumlah data, hasil alur Bagian 3,
-- dan angka laporan Bagian 4. Ekspektasi tertulis di komentar tiap blok.
-- Cara pakai (urutan lengkap membangun kondisi akhir proyek):
--   mysql -u root -p < database/skema.sql
--   mysql -u root -p < database/data-awal.sql        (kondisi awal Bagian 2)
--   ...jalankan alur transaksi Bagian 3...           (atau lihat Bagian 4)
--   mysql -u root -p < tests/pemeriksaan.sql
-- Pemeriksaan ini mengasumsikan kondisi AKHIR (13 baris riwayat).
USE tugas_db;

-- [1] Struktur: 4 tabel harus ada
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'tugas_db'
ORDER BY table_name;
-- Ekspektasi: pengguna, proyek, riwayat_status, tugas (4 baris)

-- [2] Jumlah data kondisi akhir: 6 pengguna, 3 proyek, 10 tugas, 13 riwayat
SELECT
  (SELECT COUNT(*) FROM pengguna) AS pengguna,
  (SELECT COUNT(*) FROM proyek) AS proyek,
  (SELECT COUNT(*) FROM tugas) AS tugas,
  (SELECT COUNT(*) FROM riwayat_status) AS riwayat;
-- Ekspektasi: 6, 3, 10, 13

-- [3] Integritas ENUM: email unik dijaga (duplikat ditolak)
-- (dibuktikan di Bagian 2 dengan ERROR 1062; di sini cukup hitung unik)
SELECT COUNT(DISTINCT email) AS email_unik FROM pengguna;
-- Ekspektasi: 6

-- [4] Alur Bagian 3 terekam: tugas 3 kini milik Rina dan berjalan
SELECT judul, assignee_id, status FROM tugas WHERE id = 3;
-- Ekspektasi: Uji coba beta di Play Store | 6 | berjalan

-- [5] Riwayat tugas 3: tepat 1 baris baru → berjalan
SELECT COUNT(*) AS perubahan_tugas3 FROM riwayat_status WHERE tugas_id = 3;
-- Ekspektasi: 1

-- [6] Durasi siklus tugas 2: selesai 19 hari setelah mulai
SELECT
  TIMESTAMPDIFF(DAY, MIN(waktu), MAX(waktu)) AS durasi_hari
FROM riwayat_status
WHERE tugas_id = 2;
-- Ekspektasi: 19

-- [7] Laporan overdue (acuan 2026-02-15): 4 tugas belum selesai menembus tenggat
SELECT COUNT(*) AS tugas_terlambat
FROM tugas
WHERE status <> 'selesai' AND tenggat < '2026-02-15';
-- Ekspektasi: 4

-- [8] Laporan status proyek: Kampanye Pemasaran Q1 paling tinggi persentase selesainya
SELECT
  pr.nama AS proyek,
  ROUND(
    SUM(CASE WHEN t.status = 'selesai' THEN 1 ELSE 0 END) / COUNT(t.id) * 100,
    1
  ) AS persen_selesai
FROM proyek pr
LEFT JOIN tugas t ON t.proyek_id = pr.id
GROUP BY pr.id, pr.nama
ORDER BY persen_selesai DESC;
-- Ekspektasi: Kampanye Pemasaran Q1 (83.3) teratas, lalu Migrasi Data Gudang, Rilis Aplikasi Mobile