1468 lines
52 KiB
JavaScript
1468 lines
52 KiB
JavaScript
(() => {
|
|
const API_BASE_URL = (window.DASHBOARD_API_BASE_URL || "http://127.0.0.1:3000/api").replace(/\/$/, "");
|
|
|
|
if (!window.L) {
|
|
console.error("Leaflet failed to load.");
|
|
return;
|
|
}
|
|
|
|
const state = {
|
|
mapFilter: "central",
|
|
tab: "all",
|
|
selectedAreaKey: null,
|
|
selectedOwnerKey: null,
|
|
search: "",
|
|
sortBy: "waste",
|
|
modalRequestId: 0,
|
|
modal: {
|
|
areaType: "region",
|
|
areaKey: null,
|
|
ownerName: "",
|
|
page: 1,
|
|
pageSize: 25,
|
|
search: "",
|
|
ownerType: "",
|
|
severity: "",
|
|
priorityOnly: false,
|
|
},
|
|
};
|
|
|
|
const dom = {
|
|
kpi: document.getElementById("kpi"),
|
|
mapRoot: document.getElementById("map"),
|
|
mapFilters: document.getElementById("mf"),
|
|
tabs: document.getElementById("tabs"),
|
|
legend: document.getElementById("legend"),
|
|
sidebarContent: document.getElementById("sbc"),
|
|
modal: document.getElementById("rupModal"),
|
|
modalTop: document.getElementById("modalTop"),
|
|
modalBody: document.getElementById("modalBody"),
|
|
};
|
|
|
|
if (Object.values(dom).some((element) => !element)) {
|
|
console.error("Dashboard shell is incomplete.");
|
|
return;
|
|
}
|
|
|
|
const FILTERS = [
|
|
{ key: "central", label: "Kementerian/Lembaga" },
|
|
{ key: "provinsi", label: "Pemprov" },
|
|
{ key: "kabkota", label: "Pemkot" },
|
|
{ key: "other", label: "Others" },
|
|
];
|
|
|
|
const TABS = [
|
|
{ key: "all", label: "Semua" },
|
|
{ key: "kabupaten", label: "Kabupaten" },
|
|
{ key: "kota", label: "Kota" },
|
|
];
|
|
|
|
const SEVERITY_FILTERS = [
|
|
{ key: "", label: "Semua Severity" },
|
|
{ key: "low", label: "Low" },
|
|
{ key: "med", label: "Medium" },
|
|
{ key: "high", label: "High" },
|
|
{ key: "absurd", label: "Absurd" },
|
|
];
|
|
|
|
let dashboardData = null;
|
|
let regionsByKey = new Map();
|
|
let provincesByKey = new Map();
|
|
let map = null;
|
|
let geoLayer = null;
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function escapeAttr(value) {
|
|
return String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function escapeJsString(value) {
|
|
return String(value)
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/'/g, "\\'")
|
|
.replace(/\r/g, "\\r")
|
|
.replace(/\n/g, "\\n");
|
|
}
|
|
|
|
function jsArg(value) {
|
|
if (typeof value === "boolean") {
|
|
return value ? "true" : "false";
|
|
}
|
|
if (typeof value === "number") {
|
|
return String(value);
|
|
}
|
|
return `'${escapeJsString(value)}'`;
|
|
}
|
|
|
|
function actionCall(action, ...args) {
|
|
return escapeAttr(`dashboardActions.${action}(${args.map(jsArg).join(",")})`);
|
|
}
|
|
|
|
function actionExpr(expression) {
|
|
return escapeAttr(expression);
|
|
}
|
|
|
|
function normalizeSourceId(sourceId) {
|
|
if (sourceId === null || sourceId === undefined) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = String(sourceId).trim();
|
|
if (!/^\d+$/.test(normalized)) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = Number(normalized);
|
|
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return String(parsed);
|
|
}
|
|
|
|
function buildInaprocUrl(sourceId) {
|
|
const kode = normalizeSourceId(sourceId);
|
|
return kode ? `https://data.inaproc.id/rup?kode=${encodeURIComponent(kode)}` : null;
|
|
}
|
|
|
|
function isProvinceView() {
|
|
return state.mapFilter === "provinsi";
|
|
}
|
|
|
|
function isCentralOwnerMode() {
|
|
return state.mapFilter === "central";
|
|
}
|
|
|
|
function currentAreaType() {
|
|
return isProvinceView() ? "province" : "region";
|
|
}
|
|
|
|
function formatCompactCurrency(value) {
|
|
const amount = Number(value) || 0;
|
|
const abs = Math.abs(amount);
|
|
if (abs >= 1e12) return `${(amount / 1e12).toFixed(amount % 1e12 === 0 ? 0 : 1)} T`;
|
|
if (abs >= 1e9) return `${(amount / 1e9).toFixed(amount % 1e9 === 0 ? 0 : 1)} B`;
|
|
if (abs >= 1e6) return `${(amount / 1e6).toFixed(amount % 1e6 === 0 ? 0 : 1)} M`;
|
|
if (abs >= 1e3) return `${(amount / 1e3).toFixed(amount % 1e3 === 0 ? 0 : 1)} K`;
|
|
return `${amount.toFixed(0)}`;
|
|
}
|
|
|
|
function formatCurrencyLong(value) {
|
|
const number = Math.round(Number(value) || 0);
|
|
return `Rp ${number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".")}`;
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
const number = Math.round(Number(value) || 0);
|
|
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
|
}
|
|
|
|
function formatDecimal(value) {
|
|
const amount = Number(value) || 0;
|
|
return amount % 1 === 0 ? formatNumber(amount) : amount.toFixed(2).replace(".", ",");
|
|
}
|
|
|
|
function ownerTypeLabel(value) {
|
|
if (value === "central") return "Kementerian/Lembaga";
|
|
if (value === "provinsi") return "Pemprov";
|
|
if (value === "kabkota") return "Pemkot";
|
|
if (value === "other") return "Others";
|
|
return "Tidak diketahui";
|
|
}
|
|
|
|
function ownerTypeCount(area, ownerType) {
|
|
return Number(area && area.ownerMix ? area.ownerMix[ownerType] : 0) || 0;
|
|
}
|
|
|
|
function ownerMixSummary(area) {
|
|
return `K/L ${formatNumber(ownerTypeCount(area, "central"))} | Pemprov ${formatNumber(
|
|
ownerTypeCount(area, "provinsi")
|
|
)} | Pemkot ${formatNumber(ownerTypeCount(area, "kabkota"))} | Others ${formatNumber(
|
|
ownerTypeCount(area, "other")
|
|
)}`;
|
|
}
|
|
|
|
function areaOwnerSummary(area) {
|
|
return `${activeSidebarOwnerLabel()} saja`;
|
|
}
|
|
|
|
function areaBadgeLabel(area) {
|
|
if (area.regionType === "Provinsi") return "Prov.";
|
|
if (area.regionType === "Kota") return "Kota";
|
|
return "Kab.";
|
|
}
|
|
|
|
function areaBadgeClass(area) {
|
|
return area.regionType === "Kota" ? "bk" : "bp";
|
|
}
|
|
|
|
function areaSecondaryLine(area) {
|
|
return isProvinceView() ? "Hanya paket Pemprov" : area.provinceName;
|
|
}
|
|
|
|
function severityColor(severity) {
|
|
if (severity === "absurd") return "var(--rose)";
|
|
if (severity === "high") return "var(--brick)";
|
|
if (severity === "med") return "var(--olive)";
|
|
return "var(--steel)";
|
|
}
|
|
|
|
function severityLabel(severity) {
|
|
if (severity === "absurd") return "Absurd";
|
|
if (severity === "high") return "High";
|
|
if (severity === "med") return "Medium";
|
|
return "Low";
|
|
}
|
|
|
|
function totalAreaMetrics(area) {
|
|
return {
|
|
totalPackages: Number(area?.totalPackages) || 0,
|
|
totalPriorityPackages: Number(area?.totalPriorityPackages) || 0,
|
|
totalPotentialWaste: Number(area?.totalPotentialWaste) || 0,
|
|
totalBudget: Number(area?.totalBudget) || 0,
|
|
};
|
|
}
|
|
|
|
function getActiveSidebarOwnerKey() {
|
|
return isProvinceView() ? "provinsi" : state.mapFilter;
|
|
}
|
|
|
|
function activeSidebarOwnerLabel() {
|
|
return ownerTypeLabel(getActiveSidebarOwnerKey());
|
|
}
|
|
|
|
function getAreaMetricsForOwner(area, ownerKey) {
|
|
if (!area) {
|
|
return totalAreaMetrics(null);
|
|
}
|
|
|
|
const metrics = area.ownerMetrics && area.ownerMetrics[ownerKey];
|
|
|
|
if (metrics) {
|
|
return {
|
|
totalPackages: Number(metrics.totalPackages) || 0,
|
|
totalPriorityPackages: Number(metrics.totalPriorityPackages) || 0,
|
|
totalPotentialWaste: Number(metrics.totalPotentialWaste) || 0,
|
|
totalBudget: Number(metrics.totalBudget) || 0,
|
|
};
|
|
}
|
|
|
|
if (isProvinceView() && ownerKey === "provinsi") {
|
|
return totalAreaMetrics(area);
|
|
}
|
|
|
|
return {
|
|
totalPackages: ownerTypeCount(area, ownerKey),
|
|
totalPriorityPackages: 0,
|
|
totalPotentialWaste: 0,
|
|
totalBudget: 0,
|
|
};
|
|
}
|
|
|
|
function getSidebarAreaMetrics(area) {
|
|
const ownerKey = getActiveSidebarOwnerKey();
|
|
return ownerKey ? getAreaMetricsForOwner(area, ownerKey) : totalAreaMetrics(area);
|
|
}
|
|
|
|
function renderSeverityFilterOptions(selectedValue) {
|
|
return SEVERITY_FILTERS.map(
|
|
(filter) =>
|
|
`<option value="${escapeAttr(filter.key)}"${selectedValue === filter.key ? " selected" : ""}>${escapeHtml(
|
|
filter.label
|
|
)}</option>`
|
|
).join("");
|
|
}
|
|
|
|
function getOwnerCardKey(ownerType, ownerName) {
|
|
return `${ownerType}::${ownerName}`;
|
|
}
|
|
|
|
function getAreaKey(area, areaType = currentAreaType()) {
|
|
return areaType === "province" ? area.provinceKey : area.regionKey;
|
|
}
|
|
|
|
function getAreaByKey(areaType, areaKey) {
|
|
return (areaType === "province" ? provincesByKey : regionsByKey).get(areaKey) || null;
|
|
}
|
|
|
|
function getActiveAreaByKey(areaKey) {
|
|
return getAreaByKey(currentAreaType(), areaKey);
|
|
}
|
|
|
|
function getActiveAreas() {
|
|
return isProvinceView() ? dashboardData.provinceView.provinces : dashboardData.regions;
|
|
}
|
|
|
|
function getCentralOwnersForSidebar() {
|
|
return dashboardData && dashboardData.ownerLists && Array.isArray(dashboardData.ownerLists.central)
|
|
? dashboardData.ownerLists.central
|
|
: [];
|
|
}
|
|
|
|
function getActiveGeo() {
|
|
return isProvinceView() ? dashboardData.provinceView.geo : dashboardData.geo;
|
|
}
|
|
|
|
function getActiveLegend() {
|
|
return isProvinceView() ? dashboardData.provinceView.legend : dashboardData.legend;
|
|
}
|
|
|
|
function getFeatureAreaKey(feature) {
|
|
return isProvinceView() ? feature.properties.provinceKey : feature.properties.regionKey;
|
|
}
|
|
|
|
function ensureMapStatus() {
|
|
let status = document.getElementById("mapStatus");
|
|
if (!status) {
|
|
status = document.createElement("div");
|
|
status.id = "mapStatus";
|
|
status.className = "map-status";
|
|
dom.mapRoot.parentElement.appendChild(status);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
function setMapStatus(message, isError) {
|
|
const status = ensureMapStatus();
|
|
status.className = `map-status${isError ? " error" : ""}`;
|
|
status.textContent = message;
|
|
}
|
|
|
|
function clearMapStatus() {
|
|
const status = document.getElementById("mapStatus");
|
|
if (status) {
|
|
status.remove();
|
|
}
|
|
}
|
|
|
|
function renderKpiCards(cards) {
|
|
dom.kpi.innerHTML = cards
|
|
.map(
|
|
(item) =>
|
|
`<div class="kc"><div class="kl">${escapeHtml(item.label)}</div><div class="kv">${escapeHtml(
|
|
item.value
|
|
)}</div><div class="ks">${escapeHtml(item.sublabel)}</div></div>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderSidebarMessage(message, isError) {
|
|
dom.sidebarContent.innerHTML = `<div class="panel-msg${isError ? " error" : ""}">${escapeHtml(message)}</div>`;
|
|
}
|
|
|
|
function renderModalState(title, message, isError) {
|
|
dom.modalTop.innerHTML =
|
|
`<div class="modal-top-row"><div><h2>${escapeHtml(title)}</h2><div class="msub">Audit paket pengadaan · TA 2026</div></div>` +
|
|
`<div style="display:flex;gap:8px;align-items:center"><button class="modal-close" onclick="${actionCall("closeRegionModal")}">✕ Tutup</button></div></div>`;
|
|
dom.modalBody.innerHTML = `<div class="modal-state${isError ? " error" : ""}">${escapeHtml(message)}</div>`;
|
|
}
|
|
|
|
function renderBootstrapLoading() {
|
|
renderKpiCards([
|
|
{ label: "Total Potensi Pemborosan", value: "...", sublabel: "Menghitung agregat audit" },
|
|
{ label: "Paket Prioritas Audit", value: "...", sublabel: "Memuat daftar area" },
|
|
{ label: "Total Pagu Teraudit", value: "...", sublabel: "Menyiapkan peta kab/kota dan provinsi" },
|
|
{ label: "Paket Terpetakan", value: "...", sublabel: "Memeriksa cakupan lokasi" },
|
|
]);
|
|
renderSidebarMessage("Memuat audit pengadaan per area...", false);
|
|
setMapStatus("Memuat peta audit...", false);
|
|
}
|
|
|
|
function renderBootstrapError(error) {
|
|
renderKpiCards([
|
|
{ label: "Total Potensi Pemborosan", value: "-", sublabel: "Backend belum siap" },
|
|
{ label: "Paket Prioritas Audit", value: "-", sublabel: "Periksa ingest hasil analyze" },
|
|
{ label: "Total Pagu Teraudit", value: "-", sublabel: "Ulangi db:reset bila perlu" },
|
|
{ label: "Paket Terpetakan", value: "-", sublabel: "Map belum dapat dibuat" },
|
|
]);
|
|
renderSidebarMessage(`Gagal memuat dashboard audit: ${error}`, true);
|
|
setMapStatus(`Gagal memuat dashboard audit: ${error}`, true);
|
|
}
|
|
|
|
function formatFetchError(error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
async function fetchJson(path) {
|
|
const response = await fetch(`${API_BASE_URL}${path}`);
|
|
const text = await response.text();
|
|
let payload = null;
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch (_error) {
|
|
throw new Error(`Invalid JSON response from ${path}`);
|
|
}
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(payload && payload.error ? payload.error : `Request failed (${response.status})`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function normalizeDashboardData(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
throw new Error("Bootstrap payload tidak valid.");
|
|
}
|
|
|
|
return {
|
|
summary: payload.summary || {
|
|
totalPackages: 0,
|
|
totalPriorityPackages: 0,
|
|
totalPotentialWaste: 0,
|
|
totalBudget: 0,
|
|
unmappedPackages: 0,
|
|
multiLocationPackages: 0,
|
|
},
|
|
legend: payload.legend || { zeroColor: "#243155", ranges: [] },
|
|
geo: payload.geo || { type: "FeatureCollection", features: [] },
|
|
regions: Array.isArray(payload.regions) ? payload.regions : [],
|
|
provinceView: {
|
|
legend: (payload.provinceView && payload.provinceView.legend) || { zeroColor: "#243155", ranges: [] },
|
|
geo: (payload.provinceView && payload.provinceView.geo) || { type: "FeatureCollection", features: [] },
|
|
provinces:
|
|
payload.provinceView && Array.isArray(payload.provinceView.provinces) ? payload.provinceView.provinces : [],
|
|
},
|
|
ownerLists: {
|
|
central: payload.ownerLists && Array.isArray(payload.ownerLists.central) ? payload.ownerLists.central : [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function getLegendColor(value) {
|
|
const legend = getActiveLegend();
|
|
|
|
if (!legend) {
|
|
return "#243155";
|
|
}
|
|
|
|
if (!value || value <= 0) {
|
|
return legend.zeroColor || "#243155";
|
|
}
|
|
|
|
const range = (legend.ranges || []).find((item) => value >= item.min && value <= item.max);
|
|
return range ? range.color : legend.ranges[legend.ranges.length - 1]?.color || "#a83c2e";
|
|
}
|
|
|
|
function areaMatchesCurrentView(area) {
|
|
if (!area) {
|
|
return false;
|
|
}
|
|
|
|
if (isProvinceView()) {
|
|
return area.totalPackages > 0;
|
|
}
|
|
|
|
if (state.tab === "kabupaten" && area.regionType !== "Kabupaten") {
|
|
return false;
|
|
}
|
|
|
|
if (state.tab === "kota" && area.regionType !== "Kota") {
|
|
return false;
|
|
}
|
|
|
|
if (FILTERS.some((filter) => filter.key === state.mapFilter)) {
|
|
return ownerTypeCount(area, state.mapFilter) > 0;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function getFilteredAreasForSidebar() {
|
|
let areas = getActiveAreas().filter((area) => areaMatchesCurrentView(area));
|
|
|
|
if (state.search) {
|
|
const query = state.search.toLowerCase();
|
|
const activeOwnerQuery = activeSidebarOwnerLabel().toLowerCase();
|
|
areas = areas.filter((area) => {
|
|
const matchesName = area.displayName.toLowerCase().includes(query) || area.provinceName.toLowerCase().includes(query);
|
|
|
|
if (isProvinceView()) {
|
|
return matchesName;
|
|
}
|
|
|
|
return matchesName || activeOwnerQuery.includes(query);
|
|
});
|
|
}
|
|
|
|
const metricsByAreaKey = new Map(areas.map((area) => [getAreaKey(area), getSidebarAreaMetrics(area)]));
|
|
const sorters = {
|
|
waste: (left, right) =>
|
|
metricsByAreaKey.get(getAreaKey(right)).totalPotentialWaste - metricsByAreaKey.get(getAreaKey(left)).totalPotentialWaste,
|
|
priority: (left, right) =>
|
|
metricsByAreaKey.get(getAreaKey(right)).totalPriorityPackages - metricsByAreaKey.get(getAreaKey(left)).totalPriorityPackages,
|
|
packages: (left, right) =>
|
|
metricsByAreaKey.get(getAreaKey(right)).totalPackages - metricsByAreaKey.get(getAreaKey(left)).totalPackages,
|
|
budget: (left, right) =>
|
|
metricsByAreaKey.get(getAreaKey(right)).totalBudget - metricsByAreaKey.get(getAreaKey(left)).totalBudget,
|
|
};
|
|
|
|
return areas.sort((left, right) => {
|
|
const primary = (sorters[state.sortBy] || sorters.waste)(left, right);
|
|
return primary !== 0 ? primary : left.displayName.localeCompare(right.displayName, "id");
|
|
});
|
|
}
|
|
|
|
function getFilteredOwnersForSidebar() {
|
|
let owners = getCentralOwnersForSidebar().slice();
|
|
|
|
if (state.search) {
|
|
const query = state.search.toLowerCase();
|
|
owners = owners.filter((owner) => owner.ownerName.toLowerCase().includes(query));
|
|
}
|
|
|
|
const sorters = {
|
|
waste: (left, right) => right.totalPotentialWaste - left.totalPotentialWaste,
|
|
priority: (left, right) => right.totalPriorityPackages - left.totalPriorityPackages,
|
|
packages: (left, right) => right.totalPackages - left.totalPackages,
|
|
budget: (left, right) => right.totalBudget - left.totalBudget,
|
|
};
|
|
|
|
return owners.sort((left, right) => {
|
|
const primary = (sorters[state.sortBy] || sorters.waste)(left, right);
|
|
return primary !== 0 ? primary : left.ownerName.localeCompare(right.ownerName, "id");
|
|
});
|
|
}
|
|
|
|
function renderKpis() {
|
|
const summary = dashboardData.summary;
|
|
const mappedPackages = summary.totalPackages - summary.unmappedPackages;
|
|
|
|
renderKpiCards([
|
|
{
|
|
label: "Total Potensi Pemborosan",
|
|
value: `Rp ${formatCompactCurrency(summary.totalPotentialWaste)}`,
|
|
sublabel: "Nilai nasional raw, tanpa duplikasi multi-lokasi",
|
|
},
|
|
{
|
|
label: "Paket Prioritas Audit",
|
|
value: formatNumber(summary.totalPriorityPackages),
|
|
sublabel: `${formatNumber(summary.totalPackages)} paket teraudit`,
|
|
},
|
|
{
|
|
label: "Total Pagu Teraudit",
|
|
value: `Rp ${formatCompactCurrency(summary.totalBudget)}`,
|
|
sublabel: "Akumulasi pagu dari seluruh artifact audit",
|
|
},
|
|
{
|
|
label: "Paket Terpetakan",
|
|
value: `${formatNumber(mappedPackages)} / ${formatNumber(summary.totalPackages)}`,
|
|
sublabel: `${formatNumber(summary.unmappedPackages)} unmapped | ${formatNumber(summary.multiLocationPackages)} multi-lokasi`,
|
|
},
|
|
]);
|
|
}
|
|
|
|
function renderLegend() {
|
|
const legend = getActiveLegend();
|
|
const title = isProvinceView()
|
|
? "Potensi Pemborosan Paket Pemprov per Provinsi"
|
|
: "Potensi Pemborosan per Kab/Kota";
|
|
const zeroLabel = isProvinceView() ? "Tidak ada paket pemprov terdeteksi" : "Tidak ada potensi terdeteksi";
|
|
const note = isProvinceView()
|
|
? "Agregasi provinsi mendeduplikasi paket multi-kab/kota di provinsi yang sama."
|
|
: "Map region menghitung penuh paket multi-lokasi, sehingga agregat region bisa lebih besar dari KPI nasional.";
|
|
const rows = [
|
|
`<div class="lt">${escapeHtml(title)}</div>`,
|
|
`<div class="li"><div class="lsw" style="background:${escapeAttr(legend.zeroColor || "#243155")}"></div> ${escapeHtml(
|
|
zeroLabel
|
|
)}</div>`,
|
|
];
|
|
|
|
(legend.ranges || []).forEach((range) => {
|
|
rows.push(
|
|
`<div class="li"><div class="lsw" style="background:${escapeAttr(range.color)}"></div> Rp ${escapeHtml(
|
|
formatCompactCurrency(range.min)
|
|
)} – Rp ${escapeHtml(formatCompactCurrency(range.max))}</div>`
|
|
);
|
|
});
|
|
|
|
rows.push(`<div class="legend-note">${escapeHtml(note)}</div>`);
|
|
dom.legend.innerHTML = rows.join("");
|
|
}
|
|
|
|
function renderFilterChips() {
|
|
dom.mapFilters.innerHTML = FILTERS.map(
|
|
(filter) =>
|
|
`<div class="fc${filter.key === state.mapFilter ? " a" : ""}" onclick="${actionCall("setMapFilter", filter.key)}">${escapeHtml(
|
|
filter.label
|
|
)}</div>`
|
|
).join("");
|
|
}
|
|
|
|
function renderTabs() {
|
|
const provinceView = isProvinceView();
|
|
const centralOwnerMode = isCentralOwnerMode();
|
|
|
|
dom.tabs.innerHTML = TABS.map((tab) => {
|
|
const active = provinceView || centralOwnerMode ? tab.key === "all" : tab.key === state.tab;
|
|
const disabled = (provinceView || centralOwnerMode) && tab.key !== "all";
|
|
|
|
return `<button class="stb${active ? " a" : ""}"${disabled ? " disabled" : ""} onclick="${actionCall(
|
|
"setTab",
|
|
disabled ? "all" : tab.key
|
|
)}">${escapeHtml(tab.label)}</button>`;
|
|
}).join("");
|
|
}
|
|
|
|
function sortControl() {
|
|
const placeholder = isCentralOwnerMode()
|
|
? "Cari kementerian/lembaga..."
|
|
: isProvinceView()
|
|
? "Cari provinsi..."
|
|
: "Cari kabupaten/kota...";
|
|
|
|
return (
|
|
`<div class="sw"><span class="si">🔍</span><input type="text" placeholder="${escapeAttr(
|
|
placeholder
|
|
)}" value="${escapeAttr(state.search)}" oninput="${actionExpr("dashboardActions.setSearch(this.value)")}" /></div>` +
|
|
`<div class="sort-bar"><label>Urutkan</label><select onchange="${actionExpr("dashboardActions.setSort(this.value)")}" aria-label="Urutkan area">` +
|
|
`<option value="waste"${state.sortBy === "waste" ? " selected" : ""}>Potensi Pemborosan</option>` +
|
|
`<option value="priority"${state.sortBy === "priority" ? " selected" : ""}>Paket Prioritas</option>` +
|
|
`<option value="packages"${state.sortBy === "packages" ? " selected" : ""}>Total Paket</option>` +
|
|
`<option value="budget"${state.sortBy === "budget" ? " selected" : ""}>Total Pagu</option>` +
|
|
`</select></div>`
|
|
);
|
|
}
|
|
|
|
function renderOwnerSidebarContent() {
|
|
const owners = getFilteredOwnersForSidebar();
|
|
|
|
if (!owners.length) {
|
|
dom.sidebarContent.innerHTML =
|
|
sortControl() + `<div class="panel-msg">Tidak ada kementerian/lembaga yang cocok dengan filter saat ini.</div>`;
|
|
return;
|
|
}
|
|
|
|
const maxWaste = Math.max(...owners.map((owner) => owner.totalPotentialWaste), 1);
|
|
|
|
dom.sidebarContent.innerHTML =
|
|
sortControl() +
|
|
owners
|
|
.map((owner, index) => {
|
|
const selectedClass =
|
|
state.selectedOwnerKey === getOwnerCardKey(owner.ownerType, owner.ownerName) ? " a" : "";
|
|
|
|
return (
|
|
`<div class="pi${selectedClass}" onclick="${actionCall("openOwnerModal", owner.ownerName, owner.ownerType)}">` +
|
|
`<div class="pit"><div class="pn"><span style="color:var(--t3);font-size:9px;margin-right:5px">#${index + 1}</span>${escapeHtml(
|
|
owner.ownerName
|
|
)}</div><div class="tbd bc">K/L</div></div>` +
|
|
`<div style="font-size:9.5px;color:var(--t3);margin-bottom:4px">Kementerian/Lembaga</div>` +
|
|
`<div><span class="ppv">Rp ${escapeHtml(formatCompactCurrency(owner.totalPotentialWaste))}</span><span class="ppl"> · ${escapeHtml(
|
|
formatNumber(owner.totalPriorityPackages)
|
|
)} prioritas</span></div>` +
|
|
`<div class="bw"><div class="bf" style="width:${Math.max(
|
|
4,
|
|
Math.round((owner.totalPotentialWaste / maxWaste) * 100)
|
|
)}%;background:${escapeAttr(getLegendColor(owner.totalPotentialWaste))}"></div></div>` +
|
|
`<div class="ps"><div class="pst">Total Paket: <strong>${escapeHtml(
|
|
formatNumber(owner.totalPackages)
|
|
)}</strong></div><div class="pst">Severity High: <strong>${escapeHtml(
|
|
formatNumber(owner.severityCounts.high)
|
|
)}</strong></div></div>` +
|
|
`<div class="owner-mix">Severity Absurd ${escapeHtml(formatNumber(owner.severityCounts.absurd))}</div>` +
|
|
`<div class="waste-row"><span class="waste-label">Pagu Teraudit</span><span class="waste-val">${escapeHtml(
|
|
`Rp ${formatCompactCurrency(owner.totalBudget)}`
|
|
)}</span></div>` +
|
|
`</div>`
|
|
);
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function renderSidebarContent() {
|
|
if (!dashboardData) {
|
|
renderSidebarMessage("Data dashboard belum tersedia.", true);
|
|
return;
|
|
}
|
|
|
|
if (isCentralOwnerMode()) {
|
|
renderOwnerSidebarContent();
|
|
return;
|
|
}
|
|
|
|
const areas = getFilteredAreasForSidebar();
|
|
|
|
if (!areas.length) {
|
|
dom.sidebarContent.innerHTML =
|
|
sortControl() +
|
|
`<div class="panel-msg">Tidak ada ${escapeHtml(
|
|
isProvinceView() ? "provinsi" : "region"
|
|
)} yang cocok dengan filter saat ini.</div>`;
|
|
return;
|
|
}
|
|
|
|
const areaEntries = areas.map((area) => ({
|
|
area,
|
|
metrics: getSidebarAreaMetrics(area),
|
|
}));
|
|
const maxWaste = Math.max(...areaEntries.map(({ metrics }) => metrics.totalPotentialWaste), 1);
|
|
const ownerLabel = activeSidebarOwnerLabel();
|
|
|
|
dom.sidebarContent.innerHTML =
|
|
sortControl() +
|
|
areaEntries
|
|
.map(({ area, metrics }, index) => {
|
|
const areaKey = getAreaKey(area);
|
|
const selectedClass = state.selectedAreaKey === areaKey ? " a" : "";
|
|
|
|
return (
|
|
`<div class="pi${selectedClass}" onclick="${actionCall("openAreaModal", areaKey)}">` +
|
|
`<div class="pit"><div class="pn"><span style="color:var(--t3);font-size:9px;margin-right:5px">#${index + 1}</span>${escapeHtml(
|
|
area.displayName
|
|
)}</div><div class="tbd ${areaBadgeClass(area)}">${escapeHtml(areaBadgeLabel(area))}</div></div>` +
|
|
`<div style="font-size:9.5px;color:var(--t3);margin-bottom:4px">${escapeHtml(areaSecondaryLine(area))}</div>` +
|
|
`<div><span class="ppv">Rp ${escapeHtml(formatCompactCurrency(metrics.totalPotentialWaste))}</span><span class="ppl"> · ${escapeHtml(
|
|
formatNumber(metrics.totalPriorityPackages)
|
|
)} prioritas</span></div>` +
|
|
`<div class="bw"><div class="bf" style="width:${Math.max(
|
|
4,
|
|
Math.round((metrics.totalPotentialWaste / maxWaste) * 100)
|
|
)}%;background:${escapeAttr(getLegendColor(metrics.totalPotentialWaste))}"></div></div>` +
|
|
`<div class="ps"><div class="pst">Total Paket: <strong>${escapeHtml(
|
|
formatNumber(metrics.totalPackages)
|
|
)}</strong></div><div class="pst">Pemilik: <strong>${escapeHtml(ownerLabel)}</strong></div></div>` +
|
|
`<div class="owner-mix">${escapeHtml(areaOwnerSummary(area))}</div>` +
|
|
`<div class="waste-row"><span class="waste-label">Pagu Teraudit</span><span class="waste-val">${escapeHtml(
|
|
`Rp ${formatCompactCurrency(metrics.totalBudget)}`
|
|
)}</span></div>` +
|
|
`</div>`
|
|
);
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function featureStyle(feature) {
|
|
const areaKey = getFeatureAreaKey(feature);
|
|
const area = getActiveAreaByKey(areaKey);
|
|
const visible = areaMatchesCurrentView(area);
|
|
const selected = state.selectedAreaKey === areaKey;
|
|
|
|
return {
|
|
fillColor: area ? getLegendColor(area.totalPotentialWaste) : "#243155",
|
|
fillOpacity: selected ? 0.72 : visible ? 0.52 : 0.08,
|
|
color: selected ? "#f0d8a8" : "rgba(181,168,130,0.2)",
|
|
weight: selected ? 2.1 : 0.8,
|
|
opacity: visible ? 0.85 : 0.2,
|
|
};
|
|
}
|
|
|
|
function popupHtml(area) {
|
|
if (!area) {
|
|
return `<div class="pt">Belum ada data</div>`;
|
|
}
|
|
|
|
if (isProvinceView()) {
|
|
return (
|
|
`<div class="pt">${escapeHtml(area.displayName)}</div>` +
|
|
`<div class="popup-sub">Paket Pemprov</div>` +
|
|
`<div class="pr"><span class="l">Potensi Pemborosan</span><span class="v" style="color:#b5a882">Rp ${escapeHtml(
|
|
formatCompactCurrency(area.totalPotentialWaste)
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Paket Prioritas</span><span class="v">${escapeHtml(
|
|
formatNumber(area.totalPriorityPackages)
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Total Paket</span><span class="v">${escapeHtml(
|
|
formatNumber(area.totalPackages)
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Total Pagu</span><span class="v">${escapeHtml(
|
|
formatCompactCurrency(area.totalBudget)
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Severity High</span><span class="v">${escapeHtml(
|
|
formatNumber(area.severityCounts.high)
|
|
)}</span></div>` +
|
|
`<div class="ppb"><div class="ppbf" style="width:${Math.min(
|
|
100,
|
|
area.totalPriorityPackages > 0 ? Math.round((area.totalPriorityPackages / Math.max(area.totalPackages, 1)) * 100) : 0
|
|
)}%;background:${escapeAttr(getLegendColor(area.totalPotentialWaste))}"></div></div>`
|
|
);
|
|
}
|
|
|
|
return (
|
|
`<div class="pt">${escapeHtml(area.displayName)}</div>` +
|
|
`<div class="popup-sub">${escapeHtml(area.provinceName)}</div>` +
|
|
`<div class="pr"><span class="l">Potensi Pemborosan</span><span class="v" style="color:#b5a882">Rp ${escapeHtml(
|
|
formatCompactCurrency(area.totalPotentialWaste)
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Paket Prioritas</span><span class="v">${escapeHtml(
|
|
formatNumber(area.totalPriorityPackages)
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Total Paket</span><span class="v">${escapeHtml(
|
|
formatNumber(area.totalPackages)
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Kementerian/Lembaga</span><span class="v">${escapeHtml(
|
|
formatNumber(ownerTypeCount(area, "central"))
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Pemprov</span><span class="v">${escapeHtml(
|
|
formatNumber(ownerTypeCount(area, "provinsi"))
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Pemkot</span><span class="v">${escapeHtml(
|
|
formatNumber(ownerTypeCount(area, "kabkota"))
|
|
)}</span></div>` +
|
|
`<div class="pr"><span class="l">Others</span><span class="v">${escapeHtml(
|
|
formatNumber(ownerTypeCount(area, "other"))
|
|
)}</span></div>` +
|
|
`<div class="ppb"><div class="ppbf" style="width:${Math.min(
|
|
100,
|
|
area.totalPriorityPackages > 0 ? Math.round((area.totalPriorityPackages / Math.max(area.totalPackages, 1)) * 100) : 0
|
|
)}%;background:${escapeAttr(getLegendColor(area.totalPotentialWaste))}"></div></div>`
|
|
);
|
|
}
|
|
|
|
function ensureMap() {
|
|
if (map) {
|
|
return;
|
|
}
|
|
|
|
map = L.map(dom.mapRoot, {
|
|
center: [-2.5, 118],
|
|
zoom: 5,
|
|
minZoom: 4,
|
|
maxZoom: 12,
|
|
attributionControl: false,
|
|
});
|
|
|
|
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png", {
|
|
subdomains: "abcd",
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png", {
|
|
subdomains: "abcd",
|
|
maxZoom: 19,
|
|
opacity: 0.35,
|
|
}).addTo(map);
|
|
}
|
|
|
|
function renderGeoLayer(fitToBounds) {
|
|
ensureMap();
|
|
|
|
if (geoLayer) {
|
|
geoLayer.remove();
|
|
geoLayer = null;
|
|
}
|
|
|
|
const geo = getActiveGeo();
|
|
|
|
if (!geo || !Array.isArray(geo.features) || !geo.features.length) {
|
|
setMapStatus("Tidak ada geometri untuk mode peta saat ini.", true);
|
|
return;
|
|
}
|
|
|
|
geoLayer = L.geoJSON(geo, {
|
|
style: featureStyle,
|
|
onEachFeature: (feature, layer) => {
|
|
const areaKey = getFeatureAreaKey(feature);
|
|
const area = getActiveAreaByKey(areaKey);
|
|
layer.bindPopup(popupHtml(area), { maxWidth: 320 });
|
|
layer.on({
|
|
mouseover: (event) => {
|
|
if (!area) {
|
|
return;
|
|
}
|
|
|
|
event.target.setStyle({
|
|
weight: 1.8,
|
|
color: "#f0d8a8",
|
|
fillOpacity: Math.min(featureStyle(feature).fillOpacity + 0.16, 0.85),
|
|
});
|
|
event.target.bringToFront();
|
|
},
|
|
mouseout: (event) => {
|
|
geoLayer.resetStyle(event.target);
|
|
},
|
|
click: () => {
|
|
openAreaModal(areaKey);
|
|
},
|
|
});
|
|
},
|
|
}).addTo(map);
|
|
|
|
const bounds = geoLayer.getBounds();
|
|
|
|
if (fitToBounds && bounds.isValid()) {
|
|
map.fitBounds(bounds.pad(isProvinceView() ? 0.08 : 0.05));
|
|
}
|
|
|
|
clearMapStatus();
|
|
}
|
|
|
|
function initMap() {
|
|
renderGeoLayer(true);
|
|
}
|
|
|
|
function refreshMapStyles() {
|
|
if (geoLayer) {
|
|
geoLayer.setStyle(featureStyle);
|
|
}
|
|
}
|
|
|
|
function renderPackageTableRows(items) {
|
|
return items.length
|
|
? items
|
|
.map((item) => {
|
|
const packageUrl = buildInaprocUrl(item.sourceId);
|
|
|
|
return (
|
|
`<tr${
|
|
packageUrl
|
|
? ` class="package-row-link" tabindex="0" role="link" aria-label="${escapeAttr(
|
|
`Buka ${item.packageName} di Inaproc`
|
|
)}" onclick="${actionCall("openPackageDetail", item.sourceId)}" onkeydown="${actionExpr(
|
|
`dashboardActions.handlePackageRowKeydown(event, ${jsArg(item.sourceId)})`
|
|
)}"`
|
|
: ""
|
|
}>` +
|
|
`<td class="mono">${escapeHtml(String(item.sourceId || item.id))}</td>` +
|
|
`<td class="pkg">${escapeHtml(item.packageName)}</td>` +
|
|
`<td><div class="tbl-owner">${escapeHtml(item.ownerName)}</div><div class="tbl-sub">${escapeHtml(
|
|
ownerTypeLabel(item.ownerType)
|
|
)}</div></td>` +
|
|
`<td><div class="tbl-owner">${escapeHtml(item.satker || "-")}</div><div class="tbl-sub">${escapeHtml(
|
|
item.locationRaw || "-"
|
|
)}</div></td>` +
|
|
`<td class="mono" style="color:var(--sage)">${escapeHtml(item.budget === null ? "-" : formatCurrencyLong(item.budget))}</td>` +
|
|
`<td><span class="sev-b" style="background:${escapeAttr(
|
|
item.audit.severity === "absurd"
|
|
? "rgba(212,169,153,.18)"
|
|
: item.audit.severity === "high"
|
|
? "rgba(168,60,46,.16)"
|
|
: item.audit.severity === "med"
|
|
? "rgba(139,115,50,.16)"
|
|
: "rgba(123,134,163,.16)"
|
|
)};color:${escapeAttr(severityColor(item.audit.severity))}">${escapeHtml(
|
|
severityLabel(item.audit.severity)
|
|
)}</span></td>` +
|
|
`<td class="reason">${escapeHtml(item.audit.reason || "-")}</td>` +
|
|
`</tr>`
|
|
);
|
|
})
|
|
.join("")
|
|
: `<tr><td colspan="7" class="table-empty">Tidak ada paket untuk filter saat ini.</td></tr>`;
|
|
}
|
|
|
|
function renderPagination(pagination) {
|
|
return (
|
|
`<div class="pager"><button class="pager-btn" ${pagination.page <= 1 ? "disabled" : ""} onclick="${actionCall(
|
|
"changeModalPage",
|
|
pagination.page - 1
|
|
)}">Sebelumnya</button><div class="pager-text">Halaman ${escapeHtml(formatNumber(pagination.page))} / ${escapeHtml(
|
|
formatNumber(pagination.totalPages)
|
|
)} · ${escapeHtml(formatNumber(pagination.totalItems))} paket</div><button class="pager-btn" ${
|
|
pagination.page >= pagination.totalPages ? "disabled" : ""
|
|
} onclick="${actionCall("changeModalPage", pagination.page + 1)}">Berikutnya</button></div>`
|
|
);
|
|
}
|
|
|
|
function renderRegionModalContent(payload) {
|
|
const region = payload.region;
|
|
const rowsHtml = renderPackageTableRows(payload.items);
|
|
|
|
dom.modalTop.innerHTML =
|
|
`<div class="modal-top-row"><div><h2>${escapeHtml(region.displayName)}</h2><div class="msub">${escapeHtml(
|
|
`${region.provinceName} | Audit paket pengadaan TA 2026`
|
|
)}</div></div>` +
|
|
`<div style="display:flex;gap:8px;align-items:center"><span class="tbd ${areaBadgeClass(region)}">${escapeHtml(
|
|
region.regionType
|
|
)}</span><button class="modal-close" onclick="${actionCall(
|
|
"closeRegionModal"
|
|
)}">✕ Tutup</button></div></div>` +
|
|
`<div class="modal-kpis">` +
|
|
`<div class="mkp"><div class="mkp-l">Potensi Pemborosan</div><div class="mkp-v" style="color:var(--brick)">Rp ${escapeHtml(
|
|
formatCompactCurrency(region.totalPotentialWaste)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Paket Prioritas</div><div class="mkp-v">${escapeHtml(
|
|
formatNumber(region.totalPriorityPackages)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Total Paket</div><div class="mkp-v">${escapeHtml(
|
|
formatNumber(region.totalPackages)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Total Pagu</div><div class="mkp-v" style="color:var(--sage)">Rp ${escapeHtml(
|
|
formatCompactCurrency(region.totalBudget)
|
|
)}</div></div></div>`;
|
|
|
|
dom.modalBody.innerHTML =
|
|
`<div class="modal-summary-grid">` +
|
|
`<div class="mini-stat"><span>Kementerian/Lembaga</span><strong>${escapeHtml(
|
|
formatNumber(ownerTypeCount(region, "central"))
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Pemprov</span><strong>${escapeHtml(
|
|
formatNumber(ownerTypeCount(region, "provinsi"))
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Pemkot</span><strong>${escapeHtml(
|
|
formatNumber(ownerTypeCount(region, "kabkota"))
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Others</span><strong>${escapeHtml(
|
|
formatNumber(ownerTypeCount(region, "other"))
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Severity High</span><strong>${escapeHtml(formatNumber(region.severityCounts.high))}</strong></div>` +
|
|
`<div class="mini-stat"><span>Severity Absurd</span><strong>${escapeHtml(
|
|
formatNumber(region.severityCounts.absurd)
|
|
)}</strong></div>` +
|
|
`</div>` +
|
|
`<div class="modal-filters">` +
|
|
`<input type="text" placeholder="Cari paket, lembaga, atau satker..." value="${escapeAttr(
|
|
state.modal.search
|
|
)}" oninput="${actionExpr("dashboardActions.setModalSearch(this.value)")}" />` +
|
|
`<select onchange="${actionExpr("dashboardActions.setModalOwnerType(this.value)")}" aria-label="Filter jenis pemilik">` +
|
|
`<option value="">Semua Pemilik</option><option value="central"${state.modal.ownerType === "central" ? " selected" : ""}>Kementerian/Lembaga</option>` +
|
|
`<option value="provinsi"${state.modal.ownerType === "provinsi" ? " selected" : ""}>Pemprov</option><option value="kabkota"${
|
|
state.modal.ownerType === "kabkota" ? " selected" : ""
|
|
}>Pemkot</option><option value="other"${
|
|
state.modal.ownerType === "other" ? " selected" : ""
|
|
}>Others</option></select>` +
|
|
`<select onchange="${actionExpr("dashboardActions.setModalSeverity(this.value)")}" aria-label="Filter severity">${renderSeverityFilterOptions(
|
|
state.modal.severity
|
|
)}</select>` +
|
|
`<label class="chk"><input type="checkbox" ${state.modal.priorityOnly ? "checked" : ""} onchange="${actionExpr(
|
|
"dashboardActions.setModalPriorityOnly(this.checked)"
|
|
)}" /> Hanya prioritas</label>` +
|
|
`</div>` +
|
|
`<div class="modal-cnt">Menampilkan ${escapeHtml(formatNumber(payload.items.length))} dari ${escapeHtml(
|
|
formatNumber(payload.pagination.totalItems)
|
|
)} paket pada area ini</div>` +
|
|
`<table class="rtbl"><thead><tr><th>ID</th><th>Nama Paket</th><th>Pemilik</th><th>Satker / Lokasi</th><th>Pagu</th><th>Severity</th><th>Alasan</th></tr></thead><tbody>${rowsHtml}</tbody></table>` +
|
|
renderPagination(payload.pagination);
|
|
}
|
|
|
|
function renderProvinceModalContent(payload) {
|
|
const province = payload.province;
|
|
const rowsHtml = renderPackageTableRows(payload.items);
|
|
|
|
dom.modalTop.innerHTML =
|
|
`<div class="modal-top-row"><div><h2>${escapeHtml(province.displayName)}</h2><div class="msub">Paket pemprov pada provinsi ini · TA 2026</div></div>` +
|
|
`<div style="display:flex;gap:8px;align-items:center"><span class="tbd ${areaBadgeClass(province)}">Provinsi</span><button class="modal-close" onclick="${actionCall(
|
|
"closeRegionModal"
|
|
)}">✕ Tutup</button></div></div>` +
|
|
`<div class="modal-kpis">` +
|
|
`<div class="mkp"><div class="mkp-l">Potensi Pemborosan</div><div class="mkp-v" style="color:var(--brick)">Rp ${escapeHtml(
|
|
formatCompactCurrency(province.totalPotentialWaste)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Paket Prioritas</div><div class="mkp-v">${escapeHtml(
|
|
formatNumber(province.totalPriorityPackages)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Total Paket Pemprov</div><div class="mkp-v">${escapeHtml(
|
|
formatNumber(province.totalPackages)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Total Pagu</div><div class="mkp-v" style="color:var(--sage)">Rp ${escapeHtml(
|
|
formatCompactCurrency(province.totalBudget)
|
|
)}</div></div></div>`;
|
|
|
|
dom.modalBody.innerHTML =
|
|
`<div class="modal-summary-grid">` +
|
|
`<div class="mini-stat"><span>Paket Flagged</span><strong>${escapeHtml(
|
|
formatNumber(province.totalFlaggedPackages)
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Severity Medium</span><strong>${escapeHtml(
|
|
formatNumber(province.severityCounts.med)
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Severity High</span><strong>${escapeHtml(
|
|
formatNumber(province.severityCounts.high)
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Severity Absurd</span><strong>${escapeHtml(
|
|
formatNumber(province.severityCounts.absurd)
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Avg Risk Score</span><strong>${escapeHtml(
|
|
formatDecimal(province.avgRiskScore)
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Max Risk Score</span><strong>${escapeHtml(
|
|
formatNumber(province.maxRiskScore)
|
|
)}</strong></div>` +
|
|
`</div>` +
|
|
`<div class="modal-filters">` +
|
|
`<input type="text" placeholder="Cari paket, lembaga, atau satker..." value="${escapeAttr(
|
|
state.modal.search
|
|
)}" oninput="${actionExpr("dashboardActions.setModalSearch(this.value)")}" />` +
|
|
`<select onchange="${actionExpr("dashboardActions.setModalSeverity(this.value)")}" aria-label="Filter severity">${renderSeverityFilterOptions(
|
|
state.modal.severity
|
|
)}</select>` +
|
|
`<label class="chk"><input type="checkbox" ${state.modal.priorityOnly ? "checked" : ""} onchange="${actionExpr(
|
|
"dashboardActions.setModalPriorityOnly(this.checked)"
|
|
)}" /> Hanya prioritas</label>` +
|
|
`</div>` +
|
|
`<div class="modal-cnt">Menampilkan ${escapeHtml(formatNumber(payload.items.length))} dari ${escapeHtml(
|
|
formatNumber(payload.pagination.totalItems)
|
|
)} paket pemprov pada provinsi ini</div>` +
|
|
`<table class="rtbl"><thead><tr><th>ID</th><th>Nama Paket</th><th>Pemilik</th><th>Satker / Lokasi</th><th>Pagu</th><th>Severity</th><th>Alasan</th></tr></thead><tbody>${rowsHtml}</tbody></table>` +
|
|
renderPagination(payload.pagination);
|
|
}
|
|
|
|
function renderOwnerModalContent(payload) {
|
|
const owner = payload.owner;
|
|
const rowsHtml = renderPackageTableRows(payload.items);
|
|
|
|
dom.modalTop.innerHTML =
|
|
`<div class="modal-top-row"><div><h2>${escapeHtml(owner.ownerName)}</h2><div class="msub">${escapeHtml(
|
|
`${ownerTypeLabel(owner.ownerType)} | Audit paket nasional TA 2026`
|
|
)}</div></div>` +
|
|
`<div style="display:flex;gap:8px;align-items:center"><span class="tbd bc">K/L</span><button class="modal-close" onclick="${actionCall(
|
|
"closeRegionModal"
|
|
)}">✕ Tutup</button></div></div>` +
|
|
`<div class="modal-kpis">` +
|
|
`<div class="mkp"><div class="mkp-l">Potensi Pemborosan</div><div class="mkp-v" style="color:var(--brick)">Rp ${escapeHtml(
|
|
formatCompactCurrency(owner.totalPotentialWaste)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Paket Prioritas</div><div class="mkp-v">${escapeHtml(
|
|
formatNumber(owner.totalPriorityPackages)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Total Paket</div><div class="mkp-v">${escapeHtml(
|
|
formatNumber(owner.totalPackages)
|
|
)}</div></div>` +
|
|
`<div class="mkp"><div class="mkp-l">Total Pagu</div><div class="mkp-v" style="color:var(--sage)">Rp ${escapeHtml(
|
|
formatCompactCurrency(owner.totalBudget)
|
|
)}</div></div></div>`;
|
|
|
|
dom.modalBody.innerHTML =
|
|
`<div class="modal-summary-grid">` +
|
|
`<div class="mini-stat"><span>Paket Flagged</span><strong>${escapeHtml(
|
|
formatNumber(owner.totalFlaggedPackages)
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Severity Medium</span><strong>${escapeHtml(
|
|
formatNumber(owner.severityCounts.med)
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Severity High</span><strong>${escapeHtml(
|
|
formatNumber(owner.severityCounts.high)
|
|
)}</strong></div>` +
|
|
`<div class="mini-stat"><span>Severity Absurd</span><strong>${escapeHtml(
|
|
formatNumber(owner.severityCounts.absurd)
|
|
)}</strong></div>` +
|
|
`</div>` +
|
|
`<div class="modal-filters">` +
|
|
`<input type="text" placeholder="Cari paket atau satker..." value="${escapeAttr(
|
|
state.modal.search
|
|
)}" oninput="${actionExpr("dashboardActions.setModalSearch(this.value)")}" />` +
|
|
`<select onchange="${actionExpr("dashboardActions.setModalSeverity(this.value)")}" aria-label="Filter severity">${renderSeverityFilterOptions(
|
|
state.modal.severity
|
|
)}</select>` +
|
|
`<label class="chk"><input type="checkbox" ${state.modal.priorityOnly ? "checked" : ""} onchange="${actionExpr(
|
|
"dashboardActions.setModalPriorityOnly(this.checked)"
|
|
)}" /> Hanya prioritas</label>` +
|
|
`</div>` +
|
|
`<div class="modal-cnt">Menampilkan ${escapeHtml(formatNumber(payload.items.length))} dari ${escapeHtml(
|
|
formatNumber(payload.pagination.totalItems)
|
|
)} paket pada pemilik ini</div>` +
|
|
`<table class="rtbl"><thead><tr><th>ID</th><th>Nama Paket</th><th>Pemilik</th><th>Satker / Lokasi</th><th>Pagu</th><th>Severity</th><th>Alasan</th></tr></thead><tbody>${rowsHtml}</tbody></table>` +
|
|
renderPagination(payload.pagination);
|
|
}
|
|
|
|
function renderModalContent(payload) {
|
|
if (state.modal.areaType === "owner") {
|
|
renderOwnerModalContent(payload);
|
|
return;
|
|
}
|
|
|
|
if (state.modal.areaType === "province") {
|
|
renderProvinceModalContent(payload);
|
|
return;
|
|
}
|
|
|
|
renderRegionModalContent(payload);
|
|
}
|
|
|
|
async function loadAreaPackages() {
|
|
if (
|
|
(state.modal.areaType === "owner" && (!state.modal.ownerType || !state.modal.ownerName)) ||
|
|
(state.modal.areaType !== "owner" && !state.modal.areaKey)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
state.modalRequestId += 1;
|
|
const requestId = state.modalRequestId;
|
|
renderModalState(
|
|
state.modal.areaType === "owner" ? "Memuat pemilik..." : "Memuat area...",
|
|
state.modal.areaType === "owner"
|
|
? "Mengambil paket dari pemilik terpilih..."
|
|
: "Mengambil paket dari backend audit...",
|
|
false
|
|
);
|
|
|
|
const params = new URLSearchParams({
|
|
page: String(state.modal.page),
|
|
pageSize: String(state.modal.pageSize),
|
|
});
|
|
|
|
if (state.modal.search) {
|
|
params.set("search", state.modal.search);
|
|
}
|
|
|
|
if (state.modal.areaType === "region" && state.modal.ownerType) {
|
|
params.set("ownerType", state.modal.ownerType);
|
|
}
|
|
|
|
if (state.modal.severity) {
|
|
params.set("severity", state.modal.severity);
|
|
}
|
|
|
|
if (state.modal.priorityOnly) {
|
|
params.set("priorityOnly", "true");
|
|
}
|
|
|
|
const path =
|
|
state.modal.areaType === "owner"
|
|
? (() => {
|
|
params.set("ownerType", state.modal.ownerType);
|
|
params.set("ownerName", state.modal.ownerName);
|
|
return `/owners/packages?${params.toString()}`;
|
|
})()
|
|
: state.modal.areaType === "province"
|
|
? `/provinces/${encodeURIComponent(state.modal.areaKey)}/packages?${params.toString()}`
|
|
: `/regions/${encodeURIComponent(state.modal.areaKey)}/packages?${params.toString()}`;
|
|
|
|
try {
|
|
const payload = await fetchJson(path);
|
|
|
|
if (requestId !== state.modalRequestId) {
|
|
return;
|
|
}
|
|
|
|
renderModalContent(payload);
|
|
} catch (error) {
|
|
if (requestId !== state.modalRequestId) {
|
|
return;
|
|
}
|
|
|
|
renderModalState("Gagal memuat paket", formatFetchError(error), true);
|
|
}
|
|
}
|
|
|
|
function openAreaModal(areaKey) {
|
|
state.selectedAreaKey = areaKey;
|
|
state.selectedOwnerKey = null;
|
|
state.modal = {
|
|
areaType: currentAreaType(),
|
|
areaKey,
|
|
ownerName: "",
|
|
page: 1,
|
|
pageSize: 25,
|
|
search: "",
|
|
ownerType: "",
|
|
severity: "",
|
|
priorityOnly: false,
|
|
};
|
|
|
|
refreshMapStyles();
|
|
renderSidebarContent();
|
|
dom.modal.classList.add("open");
|
|
document.body.style.overflow = "hidden";
|
|
loadAreaPackages();
|
|
}
|
|
|
|
function openOwnerModal(ownerName, ownerType) {
|
|
state.selectedAreaKey = null;
|
|
state.selectedOwnerKey = getOwnerCardKey(ownerType, ownerName);
|
|
state.modal = {
|
|
areaType: "owner",
|
|
areaKey: null,
|
|
ownerName,
|
|
page: 1,
|
|
pageSize: 25,
|
|
search: "",
|
|
ownerType,
|
|
severity: "",
|
|
priorityOnly: false,
|
|
};
|
|
|
|
refreshMapStyles();
|
|
renderSidebarContent();
|
|
dom.modal.classList.add("open");
|
|
document.body.style.overflow = "hidden";
|
|
loadAreaPackages();
|
|
}
|
|
|
|
function closeRegionModal() {
|
|
state.modalRequestId += 1;
|
|
state.modal = {
|
|
areaType: currentAreaType(),
|
|
areaKey: null,
|
|
ownerName: "",
|
|
page: 1,
|
|
pageSize: 25,
|
|
search: "",
|
|
ownerType: "",
|
|
severity: "",
|
|
priorityOnly: false,
|
|
};
|
|
dom.modal.classList.remove("open");
|
|
document.body.style.overflow = "";
|
|
}
|
|
|
|
function setSearch(value) {
|
|
state.search = value;
|
|
renderSidebarContent();
|
|
}
|
|
|
|
function setSort(value) {
|
|
state.sortBy = value;
|
|
renderSidebarContent();
|
|
}
|
|
|
|
function setTab(value) {
|
|
if (isProvinceView() || isCentralOwnerMode()) {
|
|
state.tab = "all";
|
|
renderTabs();
|
|
return;
|
|
}
|
|
|
|
state.tab = value;
|
|
refreshMapStyles();
|
|
renderTabs();
|
|
renderSidebarContent();
|
|
}
|
|
|
|
function setMapFilter(value) {
|
|
const wasProvinceView = isProvinceView();
|
|
const wasCentralOwnerMode = isCentralOwnerMode();
|
|
state.mapFilter = value;
|
|
const viewChanged = wasProvinceView !== isProvinceView();
|
|
const centralOwnerModeChanged = wasCentralOwnerMode !== isCentralOwnerMode();
|
|
|
|
if (viewChanged) {
|
|
state.tab = "all";
|
|
state.selectedAreaKey = null;
|
|
state.selectedOwnerKey = null;
|
|
closeRegionModal();
|
|
renderLegend();
|
|
renderFilterChips();
|
|
renderTabs();
|
|
renderSidebarContent();
|
|
renderGeoLayer(true);
|
|
return;
|
|
}
|
|
|
|
if (centralOwnerModeChanged) {
|
|
state.tab = "all";
|
|
state.selectedAreaKey = null;
|
|
state.selectedOwnerKey = null;
|
|
|
|
if (state.modal.areaType === "owner" && !isCentralOwnerMode()) {
|
|
closeRegionModal();
|
|
}
|
|
}
|
|
|
|
refreshMapStyles();
|
|
renderFilterChips();
|
|
renderTabs();
|
|
renderSidebarContent();
|
|
}
|
|
|
|
function setModalSearch(value) {
|
|
state.modal.search = value;
|
|
state.modal.page = 1;
|
|
loadAreaPackages();
|
|
}
|
|
|
|
function setModalOwnerType(value) {
|
|
if (state.modal.areaType === "province" || state.modal.areaType === "owner") {
|
|
return;
|
|
}
|
|
|
|
state.modal.ownerType = value;
|
|
state.modal.page = 1;
|
|
loadAreaPackages();
|
|
}
|
|
|
|
function setModalSeverity(value) {
|
|
state.modal.severity = value;
|
|
state.modal.page = 1;
|
|
loadAreaPackages();
|
|
}
|
|
|
|
function setModalPriorityOnly(value) {
|
|
state.modal.priorityOnly = Boolean(value);
|
|
state.modal.page = 1;
|
|
loadAreaPackages();
|
|
}
|
|
|
|
function changeModalPage(page) {
|
|
state.modal.page = page;
|
|
loadAreaPackages();
|
|
}
|
|
|
|
function openPackageDetail(sourceId) {
|
|
const url = buildInaprocUrl(sourceId);
|
|
if (!url) {
|
|
return;
|
|
}
|
|
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
|
|
function handlePackageRowKeydown(event, sourceId) {
|
|
if (!event) {
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
openPackageDetail(sourceId);
|
|
}
|
|
|
|
function bindEvents() {
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
closeRegionModal();
|
|
}
|
|
});
|
|
|
|
dom.modal.addEventListener("click", (event) => {
|
|
if (event.target === dom.modal) {
|
|
closeRegionModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function bootstrap() {
|
|
renderBootstrapLoading();
|
|
|
|
try {
|
|
dashboardData = normalizeDashboardData(await fetchJson("/bootstrap"));
|
|
regionsByKey = new Map(dashboardData.regions.map((region) => [region.regionKey, region]));
|
|
provincesByKey = new Map(dashboardData.provinceView.provinces.map((province) => [province.provinceKey, province]));
|
|
renderKpis();
|
|
renderLegend();
|
|
initMap();
|
|
renderFilterChips();
|
|
renderTabs();
|
|
renderSidebarContent();
|
|
} catch (error) {
|
|
renderBootstrapError(formatFetchError(error));
|
|
}
|
|
}
|
|
|
|
window.dashboardActions = {
|
|
changeModalPage,
|
|
closeRegionModal,
|
|
handlePackageRowKeydown,
|
|
openAreaModal,
|
|
openOwnerModal,
|
|
openPackageDetail,
|
|
setMapFilter,
|
|
setModalOwnerType,
|
|
setModalPriorityOnly,
|
|
setModalSearch,
|
|
setModalSeverity,
|
|
setSearch,
|
|
setSort,
|
|
setTab,
|
|
};
|
|
|
|
bindEvents();
|
|
bootstrap();
|
|
})();
|