public List<SoalAgendaDetailModel> GetSoalAgenda(int agendaId)
{
var list = new List<SoalAgendaDetailModel>();
var byId = new Dictionary<int, SoalAgendaDetailModel>();
using (var conn = new SqlConnection(_connStr))
{
conn.Open();
string qSoal = @"
SELECT ds.ID, a.NamaAgenda, bs.Pertanyaan, bs.KategoriSoal, bs.KunciJawabanLabel, ds.IsAnulir
FROM ISEE_PNP_DistribusiSoal ds
INNER JOIN ISEE_PNP_AgendaUjian a ON a.ID = ds.AgendaUjianID
INNER JOIN ISEE_PNP_BankSoal bs ON ds.BankSoalID = bs.ID
WHERE ds.AgendaUjianID = @id
ORDER BY bs.KategoriSoal, bs.Pertanyaan";
using (var cmd = new SqlCommand(qSoal, conn))
{
cmd.Parameters.AddWithValue("@id", agendaId);
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var item = new SoalAgendaDetailModel
{
DistribusiSoalID = reader.GetInt32(0),
NamaAgenda = reader.IsDBNull(1) ? "" : reader.GetString(1),
Pertanyaan = reader.IsDBNull(2) ? "" : reader.GetString(2),
KategoriSoal = reader.IsDBNull(3) ? "" : reader.GetString(3),
KunciJawaban = reader.IsDBNull(4) ? "" : reader.GetString(4),
IsAnulir = !reader.IsDBNull(5) && reader.GetBoolean(5),
OpsiList = new List<SoalAgendaOpsiModel>()
};
list.Add(item);
byId[item.DistribusiSoalID] = item;
}
}
}
if (list.Count == 0) return list;
string qOpsi = @"
SELECT ds.ID, o.LabelOpsi, o.TeksOpsi
FROM ISEE_PNP_DistribusiSoal ds
INNER JOIN ISEE_PNP_OpsiSoal o ON o.BankSoalID = ds.BankSoalID
WHERE ds.AgendaUjianID = @id
ORDER BY ds.ID, LEN(o.LabelOpsi), o.LabelOpsi";
using (var cmdOpsi = new SqlCommand(qOpsi, conn))
{
cmdOpsi.Parameters.AddWithValue("@id", agendaId);
using (var r = cmdOpsi.ExecuteReader())
{
while (r.Read())
{
SoalAgendaDetailModel soal;
if (!byId.TryGetValue(r.GetInt32(0), out soal)) continue;
soal.OpsiList.Add(new SoalAgendaOpsiModel
{
LabelOpsi = r.IsDBNull(1) ? "" : r.GetString(1),
TeksOpsi = r.IsDBNull(2) ? "" : r.GetString(2)
});
}
}
}
}
return list;
}
public void AnulirSoal(int distribusiSoalId, bool isAnulir, string userId)
{
using (var conn = new SqlConnection(_connStr))
{
conn.Open();
using (var trans = conn.BeginTransaction())
{
try
{
string q = @"
UPDATE ISEE_PNP_DistribusiSoal
SET IsAnulir = @isAnulir,
ApprovalStatus = 'Pending'
WHERE ID = @id";
using (var cmd = new SqlCommand(q, conn, trans))
{
cmd.Parameters.AddWithValue("@isAnulir", isAnulir);
cmd.Parameters.AddWithValue("@id", distribusiSoalId);
cmd.ExecuteNonQuery();
}
string namaSoal = "";
string namaAgenda = "";
using (var cmdName = new SqlCommand(@"
SELECT bs.Pertanyaan, a.NamaAgenda
FROM ISEE_PNP_DistribusiSoal ds
INNER JOIN ISEE_PNP_BankSoal bs ON ds.BankSoalID = bs.ID
INNER JOIN ISEE_PNP_AgendaUjian a ON ds.AgendaUjianID = a.ID
WHERE ds.ID = @id", conn, trans))
{
cmdName.Parameters.AddWithValue("@id", distribusiSoalId);
using (var reader = cmdName.ExecuteReader())
{
if (reader.Read())
{
namaSoal = reader.IsDBNull(0) ? "" : reader.GetString(0);
namaAgenda = reader.IsDBNull(1) ? "" : reader.GetString(1);
}
}
}
// Insert Approval Log
string actionText = isAnulir ? "Anulir" : "Batalkan Anulir";
string desc = actionText + " Soal: " + namaSoal;
string qLog = @"
INSERT INTO ISEE_PNP_ApprovalLog
(RequestType, ReferenceID, RequestDescription, ApprovalStatus, RequestedBy, RequestedAt)
VALUES (@type, @refId, @desc, 'Pending', @uid, GETDATE())";
using (var cmdLog = new SqlCommand(qLog, conn, trans))
{
cmdLog.Parameters.AddWithValue("@type", "AnulirSoal");
cmdLog.Parameters.AddWithValue("@refId", distribusiSoalId);
cmdLog.Parameters.AddWithValue("@desc", desc);
cmdLog.Parameters.AddWithValue("@uid", userId);
cmdLog.ExecuteNonQuery();
}
// Insert History Log
// Aturan penilaian: soal anulir tidak memberi nilai otomatis; jawaban benar tetap
// benar, jawaban salah tetap salah (nilai dihitung ulang saat approval disetujui).
string historyAction = isAnulir
? string.Format("Menganulir Soal: '{0}' pada Agenda '{1}' (Jawaban peserta tetap dinilai: benar tetap benar, salah tetap salah)", namaSoal, namaAgenda)
: string.Format("Membatalkan Anulir Soal: '{0}' pada Agenda '{1}' (Soal dinilai normal kembali)", namaSoal, namaAgenda);
InsertHistoryLogInTransaction(conn, trans, userId, historyAction, "DistribusiSoal", distribusiSoalId.ToString());
trans.Commit();
}
catch
{
trans.Rollback();
throw;
}
}
}
}
public List<StaffModel> GetStaffList()
{
var list = new List<StaffModel>();
using (var conn = new SqlConnection(_connStr))
{
var cmd = new SqlCommand(
"SELECT Domain_ID, Full_Name FROM v_ISEE_STAFF_DATA WITH (NOLOCK) WHERE Status = 'Aktif' ORDER BY Full_Name",
conn);
try
{
conn.Open();
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
string domainId = reader.IsDBNull(0) ? "" : reader.GetString(0);
string fullName = reader.IsDBNull(1) ? "Tanpa Nama" : reader.GetString(1);
if (!string.IsNullOrWhiteSpace(domainId))
{
list.Add(new StaffModel
{
DomainID = domainId,
FullName = fullName
});
}
}
}
}
catch (Exception ex)
{
throw new Exception("Database Error pada GetStaffList: " + ex.Message);
}
}
return list;
}
public void StopAgenda(int agendaId, string userId)
{
using (var conn = new SqlConnection(_connStr))
{
conn.Open();
using (var trans = conn.BeginTransaction())
{
try
{
string q = @"
UPDATE ISEE_PNP_AgendaUjian
SET Status = 'Berhenti',
ApprovalStatus = 'Pending',
UpdatedBy = @uid,
UpdatedAt = GETDATE()
WHERE ID = @id";
using (var cmd = new SqlCommand(q, conn, trans))
{
cmd.Parameters.AddWithValue("@uid", userId);
cmd.Parameters.AddWithValue("@id", agendaId);
cmd.ExecuteNonQuery();
}
string namaAgenda = "";
using (var cmdName = new SqlCommand("SELECT NamaAgenda FROM ISEE_PNP_AgendaUjian WHERE ID = @id", conn, trans))
{
cmdName.Parameters.AddWithValue("@id", agendaId);
namaAgenda = Convert.ToString(cmdName.ExecuteScalar()) ?? "";
}
// Insert Approval Log
string qLog = @"
INSERT INTO ISEE_PNP_ApprovalLog
(RequestType, ReferenceID, RequestDescription, ApprovalStatus, RequestedBy, RequestedAt)
VALUES (@type, @refId, @desc, 'Pending', @uid, GETDATE())";
using (var cmdLog = new SqlCommand(qLog, conn, trans))
{
cmdLog.Parameters.AddWithValue("@type", "StopAgenda");
cmdLog.Parameters.AddWithValue("@refId", agendaId);
cmdLog.Parameters.AddWithValue("@desc", "Stop Agenda: " + namaAgenda);
cmdLog.Parameters.AddWithValue("@uid", userId);
cmdLog.ExecuteNonQuery();
}
// Insert History Log
string historyAction = string.Format("Menghentikan Agenda Distribusi '{0}'", namaAgenda);
InsertHistoryLogInTransaction(conn, trans, userId, historyAction, "DistribusiSoal", agendaId.ToString());
trans.Commit();
}
catch
{
trans.Rollback();
throw;
}
}
}
}
}
}
8. DistribusiSoalHandler.ashx
<%@ WebHandler Language="C#" Class="DistribusiSoalHandler" %>
using System;
using System.IO;
using System.Web;
using System.Web.Script.Serialization;
using System.Web.SessionState;
using Models.pnp.ujian;
using Repositories.pnp.ujian;
public class DistribusiSoalHandler : IHttpHandler, IRequiresSessionState
{
private const string CH_DISTRIBUSI = "DistribusiSoal";
private const string CH_LOG_DISTRIBUSI = "LogDistribusiSoal";
public void ProcessRequest(HttpContext context)
{
context.Response.ContentType = "application/json";
JavaScriptSerializer json = new JavaScriptSerializer { MaxJsonLength = Int32.MaxValue };
try
{
string action = Convert.ToString(context.Request["action"]);
DistribusiSoalRepository repo = new DistribusiSoalRepository();
string userId = "SYSTEM";
if (context.Session != null && context.Session["DomainID"] != null)
{
userId = context.Session["DomainID"].ToString();
}
object result = null;
switch (action)
{
case "GetAgendaList":
result = repo.GetAgendaList(
context.Request["periode"],
context.Request["namaAgenda"],
context.Request["jenisSoalIds"],
context.Request["kategori"],
context.Request["status"]);
break;
case "GetAgendaById":
result = repo.GetAgendaById(Convert.ToInt32(context.Request["id"]));
break;
case "GetSoalAgenda":
result = repo.GetSoalAgenda(Convert.ToInt32(context.Request["id"]));
break;
case "SaveAgenda":
AgendaUjianModel payload = ReadPayload<AgendaUjianModel>(context);
if (payload == null)
{
throw new Exception("Payload kosong atau format JSON salah.");
}
bool isReschedule = Convert.ToString(context.Request["isReschedule"]) == "true";
repo.SaveAgenda(payload, userId, isReschedule);
result = new { success = true };
PnpDataHub.Notify(CH_DISTRIBUSI);
PnpDataHub.Notify(CH_LOG_DISTRIBUSI);
PnpDataHub.Notify("ParameterSurvey");
NotificationService.RefreshNow();
break;
case "AnulirSoal":
repo.AnulirSoal(
Convert.ToInt32(context.Request["id"]),
Convert.ToString(context.Request["isAnulir"]) == "true",
userId
);
result = new { success = true };
PnpDataHub.Notify(CH_DISTRIBUSI);
PnpDataHub.Notify(CH_LOG_DISTRIBUSI);
NotificationService.RefreshNow();
break;
case "StopAgenda":
repo.StopAgenda(Convert.ToInt32(context.Request["id"]), userId);
result = new { success = true };
PnpDataHub.Notify(CH_DISTRIBUSI);
PnpDataHub.Notify(CH_LOG_DISTRIBUSI);
NotificationService.RefreshNow();
break;
case "GetStaffList":
result = repo.GetStaffList();
break;
case "GetLogDistribusiSoal":
DateTime? dateFrom = null, dateTo = null;
DateTime tempDate;
if (!string.IsNullOrEmpty(context.Request["dateFrom"]) && DateTime.TryParse(context.Request["dateFrom"], out tempDate))
dateFrom = tempDate;
if (!string.IsNullOrEmpty(context.Request["dateTo"]) && DateTime.TryParse(context.Request["dateTo"], out tempDate))
dateTo = tempDate;
result = repo.GetLogDistribusiSoal(dateFrom, dateTo);
break;
default:
result = new { success = false, error = "Action tidak dikenali: " + action };
break;
}
context.Response.Write(json.Serialize(result));
}
catch (Exception ex)
{
context.Response.StatusCode = 500;
context.Response.Write(json.Serialize(new { success = false, error = ex.Message }));
}
}
private T ReadPayload<T>(HttpContext context)
{
context.Request.InputStream.Position = 0;
using (StreamReader reader = new StreamReader(context.Request.InputStream))
{
string body = reader.ReadToEnd();
if (string.IsNullOrEmpty(body)) return default(T);
JavaScriptSerializer serializer = new JavaScriptSerializer { MaxJsonLength = Int32.MaxValue };
return serializer.Deserialize<T>(body);
}
}
public bool IsReusable
{
get { return false; }
}
}
9. pnp.aspx
<%@ Page Language="C#" MasterPageFile="~/iSee.master" EnableEventValidation="false" AutoEventWireup="true" CodeFile="pnp.aspx.cs" Inherits="PNP" Title="iSee" MaintainScrollPositionOnPostback="true" %>
<%@ MasterType VirtualPath="~/iSee.master" %>
<asp:Content ContentPlaceHolderID="Content" runat="server">
<!-- Hidden Data User -->
<asp:Label ID="lblDomainID" Visible="false" runat="server" />
<asp:Label ID="lblProfile" Visible="false" runat="server" />
<asp:Label ID="lblStaffID" Visible="false" runat="server" />
<asp:Label ID="lblFullName" Visible="false" runat="server" />
<asp:Label ID="lblUserRole" Visible="false" runat="server" />
<script>
window.currentTrainerName = "<%= System.Web.HttpUtility.JavaScriptStringEncode(lblFullName.Text) %>";
window.PNP_USER_ROLE = "<%= System.Web.HttpUtility.JavaScriptStringEncode(lblUserRole.Text) %>";
</script>
<!-- CSS -->
<link href="<%= ResolveUrl("~/css/select2.min.css") %>" rel="stylesheet" />
<link href="<%= ResolveUrl("~/css/all.min.css") %>" rel="stylesheet" />
<link href="<%= ResolveUrl("~/css/pnp.css") %>" rel="stylesheet" />
<link href="<%= ResolveUrl("~/css/monthpickr/monthpickr.min.css") %>" rel="stylesheet" />
<link href="<%= ResolveUrl("~/css/monthpickr/monthpickr.css") %>" rel="stylesheet" />
<script src="<%= ResolveUrl("~/scripts/monthpickr/monthpickr.min.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/monthpickr/monthpickr.js") %>"></script>
<style>
.footer {
display: none;
}
.layout { margin-top: -20px; }
</style>
<div class="layout">
<!-- SIDEBAR -->
<aside class="sidebar" id="pnpSidebar">
<div class="logo">
<h2>PNP</h2>
<span>Exam System</span>
</div>
<ul class="menu">
<!-- 1. MENU ADMIN MAKER (Trainer) -->
<div ID="MenuAdminMaker" runat="server" visible="false">
<li class="menu-item" data-page="dashboard"><a href="javascript:void(0)">Dashboard</a></li>
<li class="menu-item dropdown">
<div class="dropdown-toggle"><span>Administrasi Pengaturan</span><span class="arrow">▾</span></div>
<ul class="submenu">
<li data-page="administrasi-pengaturan/parameter-kelompok-distribusi"><a href="javascript:void(0)">Parameter Kelompok Distribusi</a></li>
<li data-page="administrasi-pengaturan/parameter-scoring"><a href="javascript:void(0)">Parameter Scoring</a></li>
<li data-page="administrasi-pengaturan/parameter-survey"><a href="javascript:void(0)">Parameter Survey</a></li>
<li data-page="notifikasi/pengaturan-notifikasi"><a href="javascript:void(0)">Pengaturan Notifikasi</a></li>
<li data-page="running-text/pengaturan-running-text"><a href="javascript:void(0)">Pengaturan Running Text</a></li>
</ul>
</li>
<li class="menu-item" data-page="bank-soal/bank-soal"><a href="javascript:void(0)">Bank Soal</a></li>
<li class="menu-item" data-page="distribusi-soal/distribusi-soal"><a href="javascript:void(0)">Distribusi Soal</a></li>
<li class="menu-item" data-page="kode-referensi/kode-referensi"><a href="javascript:void(0)">Kode Referensi</a></li>
<li class="menu-item dropdown">
<div class="dropdown-toggle"><span>Report</span><span class="arrow">▾</span></div>
<ul class="submenu">
<li data-page="report/report-peserta"><a href="javascript:void(0)">Report Peserta</a></li>
<li data-page="report/report-agenda-distribusi"><a href="javascript:void(0)">Report Agenda Distribusi</a></li>
<li data-page="report/report-soal-distribusi"><a href="javascript:void(0)">Report Soal Distribusi</a></li>
<li data-page="report/report-jenis-kategori-soal"><a href="javascript:void(0)">Report Jenis dan Kategori Soal</a></li>
<li data-page="report/report-soal-distribusi-agenda"><a href="javascript:void(0)">Report Soal Distribusi Agenda</a></li>
<li data-page="report/report-survey"><a href="javascript:void(0)">Report Survey</a></li>
</ul>
</li>
</div>
<!-- 2. MENU ADMIN APPROVAL (CN014136) -->
<div ID="MenuAdminApproval" runat="server" visible="false">
<li class="menu-item" data-page="dashboard"><a href="javascript:void(0)">Dashboard</a></li>
<li class="menu-item" data-page="approval/approval"><a href="javascript:void(0)">Need Approval List</a></li>
<li class="menu-item dropdown">
<div class="dropdown-toggle"><span>Report</span><span class="arrow">▾</span></div>
<ul class="submenu">
<li data-page="report/report-peserta"><a href="javascript:void(0)">Report Peserta</a></li>
<li data-page="report/report-agenda-distribusi"><a href="javascript:void(0)">Report Agenda Distribusi</a></li>
<li data-page="report/report-soal-distribusi"><a href="javascript:void(0)">Report Soal Distribusi</a></li>
<li data-page="report/report-jenis-kategori-soal"><a href="javascript:void(0)">Report Jenis dan Kategori Soal</a></li>
<li data-page="report/report-soal-distribusi-agenda"><a href="javascript:void(0)">Report Soal Distribusi Agenda</a></li>
<li data-page="report/report-survey"><a href="javascript:void(0)">Report Survey</a></li>
</ul>
</li>
</div>
<!-- 3. MENU PESERTA (Agent, Admin, Supervisor) -->
<div ID="MenuPeserta" runat="server" visible="false">
<li class="menu-item" data-page="peserta/dashboard-peserta"><a href="javascript:void(0)">Dashboard</a></li>
<li class="menu-item" data-page="peserta/ujian-peserta"><a href="javascript:void(0)">Tes PNP</a></li>
<li class="menu-item" data-page="peserta/pencapaian-pnp"><a href="javascript:void(0)">Pencapaian PNP</a></li>
</div>
</ul>
</aside>
<!-- Overlay shown only on mobile when sidebar is open -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- CONTENT AREA -->
<main class="pnp-content">
<div class="topbar">
<button type="button" id="btnToggleSidebar" class="sidebar-toggle-btn" aria-label="Buka menu" aria-expanded="false" aria-controls="pnpSidebar">
<span class="hamburger-icon"></span>
</button>
<h2 id="page-title">Dashboard</h2>
</div>
<div id="main-content">
<div class="loading-spinner">Memuat halaman...</div>
</div>
</main>
</div>
<!-- Hidden controls untuk generate template Excel -->
<asp:HiddenField ID="hfTemplateConfig" runat="server" ClientIDMode="Static" />
<asp:Button ID="btnGenerateTemplate" runat="server" ClientIDMode="Static" Style="display:none;" OnClick="btnGenerateTemplate_Click" />
<!-- 1. CORE LIBRARIES (Wajib dimuat pertama kali) -->
<script src="<%= ResolveUrl("~/scripts/jquery-3.7.1.min.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/select2.min.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/jquery.signalR-2.4.3.min.js") %>"></script>
<script src="<%= ResolveUrl("~/signalr/hubs") %>"></script>
<script src="<%= ResolveUrl("~/scripts/xlsx.full.min.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/core/pnp-cache.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/core/pnp-table.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/core/pnp-validate.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/core/pnp-realtime.js") %>"></script>
<!-- 2. MODULE SCRIPTS -->
<script src="<%= ResolveUrl("~/scripts/pnp/dashboard/dashboard.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/dashboard/charts.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/administrasi-pengaturan/parameter-kelompok-distribusi.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/administrasi-pengaturan/parameter-scoring.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/administrasi-pengaturan/parameter-survey.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/notifikasi/pengaturan-notifikasi.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/running-text/pengaturan-running-text.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/bank-soal/upload-soal.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/bank-soal/bank-soal.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/distribusi-soal/distribusi-soal.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/approval/approval.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/kodereferensi/kode-referensi.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/peserta/ujian-peserta.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/peserta/pencapaian-pnp.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/monitoring/monitoring.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/report/report-survey.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/report/report-peserta.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/report/report-agenda-distribusi.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/report/report-soal-distribusi.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/report/report-jenis-kategori-soal.js") %>"></script>
<script src="<%= ResolveUrl("~/scripts/pnp/report/report-soal-distribusi-agenda.js") %>"></script>
<!-- 3. MAIN ROUTER -->
<script src="<%= ResolveUrl("~/scripts/pnp.js") %>"></script>
<script>
window.PNP_DOMAIN_ID = '<%= System.Web.HttpUtility.JavaScriptStringEncode(lblDomainID.Text) %>';
window.PNP_STAFF_ID = '<%= System.Web.HttpUtility.JavaScriptStringEncode(lblStaffID.Text) %>';
console.log('=== PNP USER INFO ===');
console.log('DomainID:', window.PNP_DOMAIN_ID);
console.log('StaffID :', window.PNP_STAFF_ID);
console.log('FullName:', window.currentTrainerName);
console.log('Role :', window.PNP_USER_ROLE);
</script>
</asp:Content>
10. pnp.js
// ============================================
// GLOBAL SVG ICON SYSTEM
// ============================================
window.PNP_ICONS = {
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>',
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>',
save: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>',
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
reset: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4.5l2 2H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg>',
file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>',
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
inbox: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>',
home: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5 12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7h-6v7H4a1 1 0 0 1-1-1z"/></svg>',
calendar: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
clipboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>',
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
users: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
cog: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
minus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>',
warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
lock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
list: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
filter: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>',
dice: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 4L17 6.5L11.5 10L3 7.5Z"/>
<path d="M3 7.5V16.5L11.5 20V10Z"/>
<path d="M11.5 10V20L17 16.5V6.5Z"/>
<circle cx="7.2" cy="11" r="0.8" fill="currentColor" stroke="none"/>
<circle cx="9.5" cy="14" r="0.8" fill="currentColor" stroke="none"/>
<circle cx="13.8" cy="11.5" r="0.8" fill="currentColor" stroke="none"/>
<circle cx="14.2" cy="15" r="0.8" fill="currentColor" stroke="none"/>
</svg>`,
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
pause: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>',
eyeoff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>',
stop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>',
history: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>',
usersadd: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>',
chevrondown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
ban: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>',
upload: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4"/><path d="M7 9l5-5 5 5"/><path d="M20 16v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-3"/></svg>',
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
thumbsup: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>',
thumbsdown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>',
eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
pie: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>'
};
window.pnpIcoHtml = function (name, size) {
size = size || 16;
var raw = (window.PNP_ICONS && window.PNP_ICONS[name]) || '';
return raw.replace('<svg ', '<svg width="' + size + '" height="' + size + '" ');
};
window.pnpIcon = function (name, size) {
return '<span class="pnp-ico">' + window.pnpIcoHtml(name, size) + '</span>';
};
window.paintPnpIcons = function (root) {
var scope = root || document;
if (!scope || !scope.querySelectorAll) return;
var nodes = scope.querySelectorAll('[data-ic]');
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
var name = n.getAttribute('data-ic');
var size = parseInt(n.getAttribute('data-size') || '16', 10);
n.innerHTML = window.pnpIcoHtml(name, size);
}
};
// ============================================
// DIRTY GUARD (konfirmasi tinggalkan halaman)
// ============================================
window.PNP_DIRTY = {
_check: null,
register: function (fn, opts) { this._check = fn; this._opts = opts || {}; },
clear: function () {
if (typeof this._cleanup === "function") this._cleanup();
this._check = null;
this._opts = null;
this._cleanup = null;
},
options: function () { return this._opts || {}; },
isDirty: function () {
try { return typeof this._check === "function" && !!this._check(); }
catch (e) { console.error(e); return false; }
}
};
// ============================================
// PNP ROUTER
// ============================================
const PNP = {
_loadToken: 0,
_currentPage: null,
confirmLeave: function (proceed, onCancel) {
if (!window.PNP_DIRTY.isDirty()) {
proceed();
return;
}
var opts = window.PNP_DIRTY.options();
showConfirmModal(function () {
window.PNP_DIRTY.clear();
proceed();
}, {
title: opts.title || "Tinggalkan Halaman?",
message: opts.message || "Perubahan Anda belum disimpan. Yakin ingin meninggalkan menu ini tanpa menyimpan?",
confirmText: opts.confirmText || "Ya, Tinggalkan",
cancelText: "Batal",
variant: "warning",
onCancel: onCancel
});
},
roleLabels: {
AdminMaker: "Admin Maker",
AdminApproval: "Admin Approval",
Peserta: "Peserta"
},
pageAccess: (function () {
const MAKER = "AdminMaker";
const APPROVAL = "AdminApproval";
const PESERTA = "Peserta";
const REPORT = [MAKER, APPROVAL];
return {
"dashboard": [MAKER, APPROVAL ],
"peserta/dashboard-peserta": [PESERTA],
// Admin Maker
"administrasi-pengaturan/parameter-kelompok-distribusi": [MAKER],
"administrasi-pengaturan/parameter-scoring": [MAKER],
"administrasi-pengaturan/parameter-survey": [MAKER],
"notifikasi/pengaturan-notifikasi": [MAKER],
"running-text/pengaturan-running-text": [MAKER],
"bank-soal/bank-soal": [MAKER],
"bank-soal/upload-soal": [MAKER],
"bank-soal/detail-bank-soal/detail-kategori-soal": [MAKER],
"distribusi-soal/distribusi-soal": [MAKER],
"distribusi-soal/detail-distribusi-soal/create-agenda": [MAKER],
"distribusi-soal/detail-distribusi-soal/reschedule-agenda": [MAKER],
"distribusi-soal/detail-distribusi-soal/soal-agenda": [MAKER],
"kode-referensi/kode-referensi": [MAKER],
// Report (Admin Maker dan Admin Approval)
"report/report-peserta": REPORT,
"report/report-agenda-distribusi": REPORT,
"report/report-soal-distribusi": REPORT,
"report/report-jenis-kategori-soal": REPORT,
"report/report-soal-distribusi-agenda": REPORT,
"report/report-survey": REPORT,
// Admin Approval
"approval/approval": [APPROVAL],
// Peserta
"peserta/ujian-peserta": [PESERTA],
"peserta/pencapaian-pnp": [PESERTA]
};
})(),
pageModules: {
"dashboard": () => { if (typeof initConsole === "function") initConsole(); },
"peserta/dashboard-peserta": () => { if (typeof initConsole === "function") initConsole(); },
"administrasi-pengaturan/parameter-kelompok-distribusi": () => {
if (typeof initParameterKelompokDistribusi === "function") {
initParameterKelompokDistribusi();
}
},
"administrasi-pengaturan/parameter-scoring": () => {
if (typeof initParameterScoring === "function") initParameterScoring();
},
"administrasi-pengaturan/parameter-survey": () => {
if (typeof SurveyApp.init === "function") SurveyApp.init();
},
"notifikasi/pengaturan-notifikasi": () => {
if (typeof initPengaturanNotifikasi === "function") initPengaturanNotifikasi();
},
"running-text/pengaturan-running-text": () => {
if (typeof initPengaturanRunningText === "function") initPengaturanRunningText();
},
"bank-soal/bank-soal": () => { if (typeof initBankSoal === "function") initBankSoal(); },
"bank-soal/upload-soal": () => { if (typeof initUploadSoal === "function") initUploadSoal(); },
"bank-soal/detail-bank-soal/detail-kategori-soal": () => {
if (typeof initBankSoalDetail === "function") initBankSoalDetail();
},
"distribusi-soal/distribusi-soal": () => { if (typeof initDistribusiSoal === "function") initDistribusiSoal(); },
"distribusi-soal/detail-distribusi-soal/create-agenda": () => { if (typeof CreateAgendaApp !== "undefined") CreateAgendaApp.init(); },
"distribusi-soal/detail-distribusi-soal/reschedule-agenda": () => { if (typeof initRescheduleAgenda === "function") initRescheduleAgenda(); },
"distribusi-soal/detail-distribusi-soal/soal-agenda": () => { if (typeof initSoalAgenda === "function") initSoalAgenda(); },
"kode-referensi/kode-referensi": () => { if (typeof initKodeReferensi === "function") initKodeReferensi(); },
"report/report-peserta": () => { if (typeof initReportPeserta === "function") initReportPeserta(); },
"report/report-agenda-distribusi": () => { if (typeof initReportAgendaDistribusi === "function") initReportAgendaDistribusi(); },
"report/report-soal-distribusi": () => { if (typeof initReportSoalDistribusi === "function") initReportSoalDistribusi(); },
"report/report-jenis-kategori-soal": () => { if (typeof initReportJenisKategoriSoal === "function") initReportJenisKategoriSoal(); },
"report/report-soal-distribusi-agenda": () => { if (typeof initReportSoalDistribusiAgenda === "function") initReportSoalDistribusiAgenda(); },
"report/report-survey": () => { if (typeof ReportSurvey.init === "function") ReportSurvey.init(); },
"approval/approval": () => { if (typeof initApproval === "function") initApproval(); },
"peserta/ujian-peserta": () => { if (typeof initUjianPeserta === "function") initUjianPeserta(); },
"peserta/pencapaian-pnp": () => { if (typeof initPencapaianPnp === "function") initPencapaianPnp(); }
},
// ------------------------------------------------------------------
// KONTEN HALAMAN STATE
// ------------------------------------------------------------------
statePages: {
forbidden: {
tone: "forbidden",
code: "403 Forbidden",
pageTitle: "Akses Ditolak",
title: "Anda tidak memiliki akses ke halaman ini",
desc: "Halaman ini tidak tersedia untuk peran Anda. Kembali ke dashboard, atau hubungi administrator jika Anda memerlukan akses.",
help: "Merasa seharusnya punya akses? Hubungi administrator PNP dan sebutkan halaman yang Anda buka.",
icon: '<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M32 6 10 14v16c0 13.5 9.2 24.6 22 28 12.8-3.4 22-14.5 22-28V14L32 6Z"/>' +
'<rect x="22" y="28" width="20" height="15" rx="3"/>' +
'<path d="M26 28v-4a6 6 0 0 1 12 0v4"/>' +
'<circle cx="32" cy="35.5" r="1.6" fill="currentColor" stroke="none"/>' +
'</svg>'
},
notfound: {
tone: "notfound",
code: "404 Not Found",
pageTitle: "Halaman Tidak Ditemukan",
title: "Halaman tidak ditemukan",
desc: "Alamat yang Anda buka tidak tersedia di PNP. Periksa kembali alamatnya, atau buka halaman dari menu PNP.",
help: "",
icon: '<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<circle cx="28" cy="28" r="16"/>' +
'<path d="m40 40 14 14"/>' +
'<path d="M22.5 24a5.5 5.5 0 1 1 8.2 4.8c-1.7 1-2.7 2-2.7 3.7"/>' +
'<circle cx="28" cy="37.5" r="1.5" fill="currentColor" stroke="none"/>' +
'</svg>'
},
error: {
tone: "error",
code: "Gagal memuat",
pageTitle: "Gagal Memuat Halaman",
title: "Halaman gagal dimuat",
desc: "Terjadi masalah saat memuat halaman ini. Coba muat ulang. Jika masalah berlanjut, hubungi administrator.",
help: "",
icon: window.PNP_ICONS.warning
}
},
init: async function () {
localStorage.removeItem("tempQuestions");
this.initMenu();
this.initSidebarToggle();
if (typeof initBankSoalLockConnection === "function") {
initBankSoalLockConnection();
}
let currentPage = this.getPageFromUrl();
if (!currentPage) {
const saved = localStorage.getItem("activePage");
currentPage =
(saved && this.resolveRoute(saved).status === "ok")
? saved
: this.getDefaultPageByRole();
}
this.loadPage(currentPage, true);
this.restoreActiveMenu(currentPage);
},
initMenu: function () {
const go = (page) => {
this.confirmLeave(() => {
this.loadPage(page);
this.restoreActiveMenu(page);
this.closeSidebar();
});
};
document.querySelectorAll(".menu-item[data-page]").forEach(item => {
item.addEventListener("click", () => go(item.dataset.page));
});
document.querySelectorAll(".submenu li").forEach(item => {
item.addEventListener("click", (e) => {
e.stopPropagation();
go(item.dataset.page);
});
});
document.querySelectorAll(".dropdown-toggle").forEach(toggle => {
toggle.addEventListener("click", function (e) {
e.stopPropagation();
const currentDropdown = this.parentElement;
document.querySelectorAll(".dropdown").forEach(drop => {
if (drop !== currentDropdown) drop.classList.remove("open");
});
currentDropdown.classList.toggle("open");
});
});
},
initSidebarToggle: function () {
const btn = document.getElementById("btnToggleSidebar");
const sidebar = document.querySelector(".sidebar");
const overlay = document.getElementById("sidebarOverlay");
if (!btn || !sidebar || !overlay) return;
btn.addEventListener("click", () => {
const isOpen = sidebar.classList.toggle("open");
overlay.classList.toggle("show", isOpen);
btn.setAttribute("aria-expanded", isOpen ? "true" : "false");
});
overlay.addEventListener("click", () => this.closeSidebar());
window.addEventListener("resize", () => {
if (window.innerWidth > 768) this.closeSidebar();
});
},
closeSidebar: function () {
const sidebar = document.querySelector(".sidebar");
const overlay = document.getElementById("sidebarOverlay");
const btn = document.getElementById("btnToggleSidebar");
if (sidebar) sidebar.classList.remove("open");
if (overlay) overlay.classList.remove("show");
if (btn) btn.setAttribute("aria-expanded", "false");
},
clearActive: function () {
document.querySelectorAll(".menu-item, .submenu li").forEach(item => {
item.classList.remove("active");
});
document.querySelectorAll(".dropdown").forEach(drop => {
drop.classList.remove("open");
});
},
normalizeRoute: function (route) {
return String(route || "")
.split("?")[0]
.replace(/^\/+|\/+$/g, "")
.toLowerCase();
},
getRouteMatchScore: function (currentRoute, menuRoute) {
const currentSegments = this.normalizeRoute(currentRoute).split("/");
const menuSegments = this.normalizeRoute(menuRoute).split("/");
let score = 0;
for (let i = 0; i < Math.min(currentSegments.length, menuSegments.length); i++) {
if (currentSegments[i] !== menuSegments[i]) break;
score++;
}
return score;
},
// ------------------------------------------------------------------
// AKSES
// ------------------------------------------------------------------
getUserRole: function () {
return String(window.PNP_USER_ROLE || "").trim();
},
getDefaultPageByRole: function () {
const role = this.getUserRole();
switch (role) {
case "Peserta":
return "peserta/dashboard-peserta";
case "AdminApproval":
return "dashboard";
case "AdminMaker":
return "dashboard";
default:
return "dashboard";
}
},
resolveRoute: function (page) {
const route = this.normalizeRoute(page);
const registered = route !== "" &&
Object.prototype.hasOwnProperty.call(this.pageAccess, route);
if (!registered) return { status: "notfound", route: route };
if (this.pageAccess[route].indexOf(this.getUserRole()) === -1) {
return { status: "forbidden", route: route };
}
return { status: "ok", route: route };
},
escapeHtml: function (value) {
return String(value == null ? "" : value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
},
renderStatePage: function (type, route, options) {
options = options || {};
const content = document.getElementById("main-content");
if (!content) return;
const cfg = this.statePages[type] || this.statePages.error;
const fullPath = route ? "/pnp/" + route : "/pnp";
const shownPath = fullPath.length > 120 ? fullPath.substring(0, 117) + "..." : fullPath;
const rows = [
'<li><span class="k">Halaman diminta</span><code class="v">' + this.escapeHtml(shownPath) + '</code></li>'
];
if (type === "forbidden") {
const role = this.getUserRole();
rows.push('<li><span class="k">Peran Anda</span><span class="v">' +
this.escapeHtml(this.roleLabels[role] || role || "-") + '</span></li>');
}
rows.push('<li><span class="k">Kode</span><span class="v">' + this.escapeHtml(cfg.code) + '</span></li>');
const backIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>';
let actions = "";
if (type === "error" && typeof options.onRetry === "function") {
actions += '<button type="button" class="btn-primary" data-state-action="retry">' + window.pnpIcon("reset", 16) + ' Muat ulang</button>';
actions += '<button type="button" class="btn-secondary" data-state-action="home">' + window.pnpIcon("home", 16) + ' Ke dashboard</button>';
} else {
actions += '<button type="button" class="btn-primary" data-state-action="home">' + window.pnpIcon("home", 16) + ' Kembali ke dashboard</button>';
if (window.history.length > 1) {
actions += '<button type="button" class="btn-secondary" data-state-action="back">' + backIcon + ' Halaman sebelumnya</button>';
}
}
content.innerHTML =
'<div class="pnp-state pnp-state--' + cfg.tone + '">' +
'<div class="pnp-state-card"' + (type === "notfound" ? "" : ' role="alert"') + '>' +
'<div class="pnp-state-icon">' + cfg.icon + '</div>' +
'<h3 class="pnp-state-title">' + cfg.title + '</h3>' +
'<p class="pnp-state-desc">' + cfg.desc + '</p>' +
'<ul class="pnp-state-meta">' + rows.join("") + '</ul>' +
'<div class="pnp-state-actions">' + actions + '</div>' +
(cfg.help ? '<p class="pnp-state-help">' + cfg.help + '</p>' : "") +
'</div>' +
'</div>';
const bind = (name, handler) => {
const el = content.querySelector('[data-state-action="' + name + '"]');
if (el && typeof handler === "function") el.addEventListener("click", handler);
};
bind("home", () => window.goToPage(this.getDefaultPageByRole()));
bind("back", () => window.history.back());
bind("retry", options.onRetry);
const title = document.getElementById("page-title");
if (title) title.innerText = cfg.pageTitle;
},
restoreActiveMenu: function (page) {
this.clearActive();
if (this.resolveRoute(page).status !== "ok") return;
const currentRoute = this.normalizeRoute(page);
const menuItems = [...document.querySelectorAll(".menu [data-page]")];
let activeItem = menuItems.find(item =>
this.normalizeRoute(item.dataset.page) === currentRoute
);
if (!activeItem) {
activeItem = menuItems
.map(item => ({
item,
score: this.getRouteMatchScore(currentRoute, item.dataset.page)
}))
.filter(result => result.score > 0)
.sort((a, b) => b.score - a.score)[0]?.item;
}
if (!activeItem) {
console.warn(`Menu untuk route "${currentRoute}" tidak ditemukan.`);
return;
}
activeItem.classList.add("active");
const dropdown = activeItem.closest(".dropdown");
if (dropdown) {
dropdown.classList.add("active", "open");
}
},
loadPage: function (page, skipPushState = false) {
if (typeof window.__onLeavePage === "function") {
try {
window.__onLeavePage();
} catch (e) {
console.error(e);
}
window.__onLeavePage = null;
}
window.PNP_DIRTY.clear();
const content = document.getElementById("main-content");
const token = ++this._loadToken;
const result = this.resolveRoute(page);
const pagePath = result.route;
this._currentPage = page;
if (!skipPushState) {
const cleanUrl = page === 'dashboard' ? '/pnp' : `/pnp/${page}`;
history.pushState({ page: page }, "", cleanUrl);
}
if (result.status !== "ok") {
this.clearActive();
this.renderStatePage(result.status, pagePath);
return;
}
content.innerHTML = '<div class="loading-spinner">Memuat halaman...</div>';
localStorage.setItem("activePage", page);
const filePath = "/content-pnp/" + pagePath + ".html";
fetch(filePath)
.then(response => {
if (!response.ok) {
const err = new Error("HTTP " + response.status + ": " + filePath);
err.status = response.status;
throw err;
}
return response.text();
})
.then(html => {
if (token !== this._loadToken) return;
content.innerHTML = html;
this.initPageModule(pagePath);
this.initSelect2(content);
if (typeof window.paintPnpIcons === "function") {
window.paintPnpIcons(content);
}
document.getElementById("page-title").innerText = this.formatTitle(pagePath.split("/").pop());
if (typeof initAutoResize === "function") initAutoResize(content);
})
.catch(error => {
if (token !== this._loadToken) return;
console.error("Gagal load page:", page, error);
if (error && error.status === 404) {
this.renderStatePage("notfound", pagePath);
} else {
this.renderStatePage("error", pagePath, { onRetry: () => this.loadPage(page, true) });
}
});
},
getPageFromUrl: function () {
const path = window.location.pathname;
if (path.startsWith("/pnp/")) {
return path.substring(5);
}
if (path === "/pnp" || path === "/pnp/") {
return this.getDefaultPageByRole();
}
return null;
},
initPageModule: function (pagePath) {
const moduleFn = this.pageModules[pagePath];
if (typeof moduleFn === "function") {
moduleFn();
} else {
console.warn(`Modul untuk halaman "${pagePath}" belum diimplementasikan.`);
}
},
initSelect2: function (scope = document) {
$(scope).find(".select2").each(function () {
if ($(this).hasClass("select2-hidden-accessible")) {
$(this).select2("destroy");
}
$(this).select2({
width: "100%",
placeholder: "Pilih",
allowClear: true
});
});
},
formatTitle: function (text) {
return text.replace(/-/g, " ").replace(/\b\w/g, l => l.toUpperCase());
}
};
window.goToPage = function (page) {
PNP.loadPage(page);
PNP.restoreActiveMenu(page);
};
window.addEventListener("popstate", function () {
const page = PNP.getPageFromUrl() || PNP.getDefaultPageByRole();
const previous = PNP._currentPage;
PNP.confirmLeave(function () {
PNP.loadPage(page, true);
PNP.restoreActiveMenu(page);
}, function () {
// batal: kembalikan URL ke halaman semula
if (previous) {
history.pushState({ page: previous }, "", previous === "dashboard" ? "/pnp" : "/pnp/" + previous);
}
});
});
// Peringatan bawaan browser saat refresh/tutup tab
window.addEventListener("beforeunload", function (e) {
if (window.PNP_DIRTY.isDirty() && window.PNP_DIRTY.options().warnOnUnload !== false) {
e.preventDefault();
e.returnValue = "";
}
});
document.addEventListener("DOMContentLoaded", function () {
PNP.init();
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Enter") return;
const target = event.target;
const tag = target.tagName ? target.tagName.toUpperCase() : "";
if (tag === "TEXTAREA") return;
const isTextLikeInput = tag === "INPUT" && [
"text", "search", "number", "email", "tel", "url", "password"
].indexOf((target.type || "text").toLowerCase()) !== -1;
const isSelect = tag === "SELECT";
if (!isTextLikeInput && !isSelect) return;
if (!target.closest("#main-content")) return;
event.preventDefault();
event.stopPropagation();
const focusable = Array.prototype.slice.call(
document.querySelectorAll(
"#main-content input:not([disabled]):not([readonly]), " +
"#main-content select:not([disabled])"
)
).filter(function (el) { return el.offsetParent !== null; });
const idx = focusable.indexOf(target);
if (idx !== -1 && idx < focusable.length - 1) {
focusable[idx + 1].focus();
} else {
target.blur();
}
}, true);
// ============================================
// TOAST MESSAGE
// ============================================
function showToast(message, success) {
let toast = document.getElementById("toastTemplate");
if (!toast) {
toast = document.createElement("div");
toast.id = "toastTemplate";
document.body.appendChild(toast);
}
const icon = typeof window.pnpIcon === "function"
? window.pnpIcon(success ? "check" : "close", 18)
: "";
toast.innerHTML = icon + "<span>" + message + "</span>";
toast.className = "toast-template show " + (success ? "toast-success" : "toast-error");
setTimeout(() => {
toast.className = "toast-template";
}, 4000);
}
window.getPageParams = function () {
const directParams = new URLSearchParams(window.location.search);
if (directParams.has("id")) {
return directParams;
}
const page = directParams.get("page") || "";
const split = page.split("?");
return new URLSearchParams(split[1] || "");
};
// ============================================
// PNP CONFIRM MODAL - Global Function
// ============================================
var PnpConfirmModal = (function () {
var overlay = null;
var isOpen = false;
var ICONS = {
confirm: '<span style="font-size:38px;font-weight:500;line-height:1;">?</span>',
danger: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>',
success: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>',
warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>'
};
function createModal() {
if (overlay) return;
overlay = document.createElement('div');
overlay.className = 'pnp-confirm-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML =
'<div class="pnp-confirm-card">' +
'<div class="pnp-confirm-body">' +
'<div class="pnp-confirm-icon primary" id="pnpConfirmIcon"></div>' +
'<h3 class="pnp-confirm-title" id="pnpConfirmTitle">Konfirmasi</h3>' +
'<p class="pnp-confirm-message" id="pnpConfirmMessage"></p>' +
'</div>' +
'<div class="pnp-confirm-footer">' +
'<button type="button" class="pnp-confirm-btn pnp-confirm-btn-cancel" id="pnpConfirmCancel">Batal</button>' +
'<button type="button" class="pnp-confirm-btn pnp-confirm-btn-confirm" id="pnpConfirmConfirm">Ya, Lanjutkan</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
}
function getVariant(options) {
var variant = options.variant || 'primary';
var allowed = ['primary', 'danger', 'success', 'warning'];
return allowed.indexOf(variant) !== -1 ? variant : 'primary';
}
function showConfirmModal(callback, options) {
options = options || {};
createModal();
if (isOpen) return;
isOpen = true;
var title = options.title || 'Konfirmasi';
var message = options.message || 'Apakah Anda yakin ingin melanjutkan tindakan ini?';
var confirmText = options.confirmText || 'Ya, Lanjutkan';
var cancelText = options.cancelText || 'Batal';
var variant = getVariant(options);
var closeOnOverlay = options.closeOnOverlay !== false;
var iconEl = document.getElementById('pnpConfirmIcon');
var titleEl = document.getElementById('pnpConfirmTitle');
var messageEl = document.getElementById('pnpConfirmMessage');
var confirmBtn = document.getElementById('pnpConfirmConfirm');
var cancelBtn = document.getElementById('pnpConfirmCancel');
iconEl.className = 'pnp-confirm-icon ' + variant;
iconEl.innerHTML = ICONS[variant] || ICONS.confirm;
titleEl.textContent = title;
messageEl.textContent = message;
confirmBtn.textContent = confirmText;
cancelBtn.textContent = cancelText;
confirmBtn.className = 'pnp-confirm-btn pnp-confirm-btn-confirm ' + variant;
confirmBtn.disabled = false;
overlay.classList.add('show');
document.body.style.overflow = 'hidden';
setTimeout(function () {
confirmBtn.focus();
}, 200);
function handleConfirm() {
if (options.showLoading) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="pnp-confirm-spinner"></span> Memproses...';
}
if (typeof callback === 'function') {
try {
callback();
} catch (e) {
console.error('Callback error:', e);
}
}
close();
}
function handleCancel() {
if (typeof options.onCancel === 'function') {
options.onCancel();
}
close();
}
function handleOverlayClick(e) {
if (closeOnOverlay && e.target === overlay) {
handleCancel();
}
}
function handleEscKey(e) {
if (e.key === 'Escape') handleCancel();
}
confirmBtn.onclick = handleConfirm;
cancelBtn.onclick = handleCancel;
overlay.onclick = handleOverlayClick;
document.addEventListener('keydown', handleEscKey);
function close() {
overlay.classList.remove('show');
document.body.style.overflow = '';
isOpen = false;
document.removeEventListener('keydown', handleEscKey);
setTimeout(function () {
confirmBtn.disabled = false;
confirmBtn.textContent = confirmText;
}, 300);
}
}
return {
show: showConfirmModal
};
})();
// ============================================
// GLOBAL WRAPPER (Backward Compatible)
// ============================================
function showConfirmModal(callback, options) {
PnpConfirmModal.show(callback, options);
}
window.goToAbsolute = function (path) {
window.location.href = path.startsWith("/") ? path : "/" + path;
};
// ============================================
// ONCLICK INPUT GLOBAL DATE/MONTH PICKER
// ============================================
document.addEventListener("pointerdown", function (event) {
const input = event.target.closest('input[type="date"], input[type="month"]');
if (!input || input.disabled || input.readOnly) return;
if (typeof input.showPicker === "function") {
try { input.showPicker(); } catch (e) { /* biarkan default browser */ }
}
});
// ============================================
// MONTH PICKER COMPONENT — cross-browser reusable
// ============================================
window.initFlatpickr = function (selector, options) {
if (typeof flatpickr === 'undefined') {
console.warn('Flatpickr belum dimuat. Pastikan CDN Flatpickr sudah di-include.');
return null;
}
var defaults = {
allowInput: true,
disableMobile: true,
clickOpens: true
};
var config = Object.assign({}, defaults, options || {});
return flatpickr(selector, config);
};
document.addEventListener('DOMContentLoaded', function () {
if (typeof flatpickr !== 'undefined' && typeof monthSelectPlugin !== 'undefined') {
flatpickr('.flatpickr-month', {
plugins: [new monthSelectPlugin({
shorthand: false,
dateFormat: "Y-m",
altFormat: "F Y"
})],
allowInput: true,
disableMobile: true
});
}
});
document.addEventListener("input", function (event) {
const fieldIds = [
"namaAgenda",
"periodeBerjalan",
"tanggalMulai",
"tanggalSelesai",
"durasi"
];
if (fieldIds.includes(event.target.id)) {
if (typeof PnpValidator !== "undefined" && typeof PnpValidator.clearFieldError === "function") {
PnpValidator.clearFieldError(event.target);
}
}
});
document.addEventListener("change", function (event) {
const fieldIds = [
"periodeBerjalan",
"tanggalMulai",
"tanggalSelesai",
"kelompokDistribusi",
"filterCreatedFrom",
"filterCreatedTo",
"filterUpdatedFrom",
"filterUpdatedTo"
];
if (fieldIds.includes(event.target.id)) {
if (typeof PnpValidator !== "undefined" && typeof PnpValidator.clearFieldError === "function") {
PnpValidator.clearFieldError(event.target);
}
}
});
function formatDate(dateValue, withTime = false) {
if (!dateValue) return "-";
try {
let date;
if (typeof dateValue === "string") {
const match = dateValue.match(/\/Date\((\d+)\)\//);
if (match) {
date = new Date(parseInt(match[1], 10));
} else {
date = new Date(dateValue);
}
} else {
date = new Date(dateValue);
}
if (isNaN(date.getTime())) {
return "-";
}
const options = {
day: "2-digit",
month: "long",
year: "numeric"
};
if (withTime) {
options.hour = "2-digit";
options.minute = "2-digit";
options.second = "2-digit";
options.hour12 = false;
}
return date.toLocaleString("id-ID", options);
} catch (err) {
console.error("formatDate error:", err, dateValue);
return "-";
}
}
function initDateRange(fromId, toId) {
var fromEl = document.getElementById(fromId);
var toEl = document.getElementById(toId);
if (!fromEl || !toEl) {
return [];
}
var fpTo = flatpickr(toEl, {
dateFormat: "Y-m-d",
allowInput: true,
disableMobile: true,
onChange: function (selectedDates) {
if (
selectedDates.length &&
fpFrom.selectedDates.length &&
selectedDates[0] < fpFrom.selectedDates[0]
) {
fpTo.clear();
showToast(
"Tanggal akhir tidak boleh lebih kecil dari tanggal awal.",
false
);
}
}
});
var fpFrom = flatpickr(fromEl, {
dateFormat: "Y-m-d",
allowInput: true,
disableMobile: true,
onChange: function (selectedDates) {
if (selectedDates.length > 0) {
fpTo.set("minDate", selectedDates[0]);
if (
fpTo.selectedDates.length > 0 &&
fpTo.selectedDates[0] < selectedDates[0]
) {
fpTo.clear();
}
} else {
fpTo.set("minDate", null);
}
}
});
if (fromEl.value) {
fpTo.set("minDate", fromEl.value);
}
return [fpFrom, fpTo];
}
function resetDateRange(fromId, toId) {
var fromEl = document.getElementById(fromId);
var toEl = document.getElementById(toId);
if (fromEl && fromEl._flatpickr) {
fromEl._flatpickr.clear();
}
if (toEl && toEl._flatpickr) {
toEl._flatpickr.clear();
toEl._flatpickr.set("minDate", null);
}
if (fromEl) {
fromEl.value = "";
}
if (toEl) {
toEl.value = "";
}
}3 views