-- Migrasi: target user untuk Agenda Survey Non PNP
-- Data lama otomatis menjadi 'All' (Semua User), jadi perilaku lama tidak berubah.
IF COL_LENGTH('dbo.ISEE_PNP_AgendaSurveyNonPNP', 'TujuanType') IS NULL
ALTER TABLE dbo.ISEE_PNP_AgendaSurveyNonPNP
ADD TujuanType NVARCHAR(30) NOT NULL
CONSTRAINT DF_AgendaSurveyNonPNP_TujuanType DEFAULT 'All';
GO
IF COL_LENGTH('dbo.ISEE_PNP_AgendaSurveyNonPNP', 'TargetPosition') IS NULL
ALTER TABLE dbo.ISEE_PNP_AgendaSurveyNonPNP ADD TargetPosition NVARCHAR(MAX) NULL;
GO
IF COL_LENGTH('dbo.ISEE_PNP_AgendaSurveyNonPNP', 'TargetJobTitle') IS NULL
ALTER TABLE dbo.ISEE_PNP_AgendaSurveyNonPNP ADD TargetJobTitle NVARCHAR(MAX) NULL;
GO
IF OBJECT_ID('dbo.ISEE_PNP_AgendaSurveyNonPNPTarget', 'U') IS NULL
BEGIN
CREATE TABLE dbo.ISEE_PNP_AgendaSurveyNonPNPTarget
(
ID INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
AgendaNonPNPID INT NOT NULL,
DomainID NVARCHAR(100) NOT NULL
);
CREATE INDEX IX_AgendaSurveyNonPNPTarget_Agenda
ON dbo.ISEE_PNP_AgendaSurveyNonPNPTarget (AgendaNonPNPID, DomainID);
END
GO
# Patch: Target User pada Agenda Survey Non PNP
Urutan pengerjaan: jalankan SQL `01-migrasi-target-survey-nonpnp.sql` dulu, lalu terapkan patch di bawah.
Pola target meniru Running Text: `All` (Semua User), `PositionJobTitle` (cocok Position DAN Job Title), `Manual` (daftar Domain ID).
Data lama otomatis `All`, jadi survey yang sudah ada tetap tampil untuk semua user.
File yang berubah: ParameterSurveyModels.cs, ParameterSurveyRepository.cs, parameter-survey.html, parameter-survey.js, UjianPesertaRepository.cs, UjianPesertaHandler.ashx.
ParameterSurveyHandler.ashx dan ujian-peserta.js tidak perlu diubah.
---
## 1. ParameterSurveyModels.cs
Ganti class `AgendaSurveyNonPNPModel` dan `SaveAgendaNonPNPRequest`:
```csharp
public class AgendaSurveyNonPNPModel
{
public int ID { get; set; }
public string NamaAgenda { get; set; }
public int PertanyaanID { get; set; }
public string NamaTemplate { get; set; }
public string StartDate { get; set; }
public string EndDate { get; set; }
public string TujuanType { get; set; }
public string TujuanDisplay { get; set; }
public List<string> TargetPositions { get; set; }
public List<string> TargetJobTitles { get; set; }
public List<string> ManualDomainIds { get; set; }
public List<PertanyaanTemplateModel> DaftarPertanyaan { get; set; }
public AgendaSurveyNonPNPModel()
{
TujuanType = "All";
TargetPositions = new List<string>();
TargetJobTitles = new List<string>();
ManualDomainIds = new List<string>();
DaftarPertanyaan = new List<PertanyaanTemplateModel>();
}
}
public class SaveAgendaNonPNPRequest
{
public int ID { get; set; }
public string NamaAgenda { get; set; }
public int PertanyaanID { get; set; }
public string StartDate { get; set; }
public string EndDate { get; set; }
public string TujuanType { get; set; }
public List<string> TargetPositions { get; set; }
public List<string> TargetJobTitles { get; set; }
public List<string> ManualDomainIds { get; set; }
public SaveAgendaNonPNPRequest()
{
TujuanType = "All";
TargetPositions = new List<string>();
TargetJobTitles = new List<string>();
ManualDomainIds = new List<string>();
}
}
```
---
## 2. ParameterSurveyRepository.cs
Tambahkan `using System.Linq;` di bagian atas.
### 2a. Helper baru (taruh dekat `GetSafeUserId`)
```csharp
private const string TARGET_DELIMITER = "|";
private static string NormalizeTujuanType(string type)
{
return (type == "PositionJobTitle" || type == "Manual") ? type : "All";
}
private static string JoinTargets(List<string> items)
{
if (items == null) return null;
var clean = items
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim().Replace(TARGET_DELIMITER, " "))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return clean.Count == 0 ? null : string.Join(TARGET_DELIMITER, clean);
}
private static List<string> SplitTargets(string value)
{
if (string.IsNullOrWhiteSpace(value)) return new List<string>();
return value.Split(new[] { TARGET_DELIMITER[0] })
.Select(x => x.Trim())
.Where(x => x.Length > 0)
.ToList();
}
private static string BuildTujuanDisplay(
string type, List<string> positions, List<string> jobTitles, int manualCount)
{
if (type == "PositionJobTitle")
{
return "Position: " + (positions.Count > 0 ? string.Join(", ", positions) : "-")
+ " / Job Title: " + (jobTitles.Count > 0 ? string.Join(", ", jobTitles) : "-");
}
if (type == "Manual") return "Manual (" + manualCount + " user)";
return "Semua User";
}
private static void ReplaceManualTargetsInTransaction(
SqlConnection conn, SqlTransaction trans, int agendaId, List<string> domainIds)
{
using (var del = new SqlCommand(
"DELETE FROM ISEE_PNP_AgendaSurveyNonPNPTarget WHERE AgendaNonPNPID = @id", conn, trans))
{
del.Parameters.Add("@id", SqlDbType.Int).Value = agendaId;
del.ExecuteNonQuery();
}
foreach (var domainId in domainIds)
{
using (var ins = new SqlCommand(
"INSERT INTO ISEE_PNP_AgendaSurveyNonPNPTarget (AgendaNonPNPID, DomainID) VALUES (@id, @did)",
conn, trans))
{
ins.Parameters.Add("@id", SqlDbType.Int).Value = agendaId;
ins.Parameters.Add("@did", SqlDbType.NVarChar, 100).Value = domainId;
ins.ExecuteNonQuery();
}
}
}
```
### 2b. Ganti seluruh method `GetAgendaNonPNPList`
```csharp
public List<AgendaSurveyNonPNPModel> GetAgendaNonPNPList()
{
var list = new List<AgendaSurveyNonPNPModel>();
using (var conn = new SqlConnection(connString))
{
conn.Open();
const string query = @"
SELECT
agenda.ID,
agenda.NamaAgenda,
agenda.PertanyaanID,
agenda.StartDate,
agenda.EndDate,
template.NamaPertanyaan,
ISNULL(agenda.TujuanType, 'All'),
agenda.TargetPosition,
agenda.TargetJobTitle
FROM ISEE_PNP_AgendaSurveyNonPNP agenda
LEFT JOIN ISEE_PNP_PertanyaanSurvey template
ON agenda.PertanyaanID = template.ID
ORDER BY agenda.ID DESC";
using (var cmd = new SqlCommand(query, conn))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
list.Add(new AgendaSurveyNonPNPModel
{
ID = reader.GetInt32(0),
NamaAgenda = reader.IsDBNull(1) ? "" : Convert.ToString(reader.GetValue(1)),
PertanyaanID = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)),
StartDate = reader.IsDBNull(3) ? "" : Convert.ToDateTime(reader.GetValue(3)).ToString("yyyy-MM-dd"),
EndDate = reader.IsDBNull(4) ? "" : Convert.ToDateTime(reader.GetValue(4)).ToString("yyyy-MM-dd"),
NamaTemplate = reader.IsDBNull(5) ? "" : Convert.ToString(reader.GetValue(5)),
TujuanType = NormalizeTujuanType(Convert.ToString(reader.GetValue(6))),
TargetPositions = SplitTargets(reader.IsDBNull(7) ? null : Convert.ToString(reader.GetValue(7))),
TargetJobTitles = SplitTargets(reader.IsDBNull(8) ? null : Convert.ToString(reader.GetValue(8)))
});
}
}
// Target manual (reader sebelumnya sudah ditutup)
var byId = list.ToDictionary(x => x.ID);
using (var cmdTarget = new SqlCommand(
"SELECT AgendaNonPNPID, DomainID FROM ISEE_PNP_AgendaSurveyNonPNPTarget ORDER BY ID", conn))
using (var rTarget = cmdTarget.ExecuteReader())
{
while (rTarget.Read())
{
AgendaSurveyNonPNPModel model;
if (byId.TryGetValue(rTarget.GetInt32(0), out model) && !rTarget.IsDBNull(1))
model.ManualDomainIds.Add(rTarget.GetString(1));
}
}
foreach (var agenda in list)
{
agenda.TujuanDisplay = BuildTujuanDisplay(
agenda.TujuanType, agenda.TargetPositions, agenda.TargetJobTitles, agenda.ManualDomainIds.Count);
agenda.DaftarPertanyaan = agenda.PertanyaanID > 0
? GetPertanyaanByTemplateID(conn, agenda.PertanyaanID)
: new List<PertanyaanTemplateModel>();
}
}
return list;
}
```
### 2c. Ganti seluruh method `SaveAgendaNonPNP`
```csharp
public int SaveAgendaNonPNP(SaveAgendaNonPNPRequest req, string userId)
{
if (req == null) throw new ArgumentNullException("req");
if (string.IsNullOrWhiteSpace(req.NamaAgenda)) throw new Exception("Nama agenda wajib diisi.");
if (req.PertanyaanID <= 0) throw new Exception("Template pertanyaan wajib dipilih.");
DateTime startDate, endDate;
if (!DateTime.TryParse(req.StartDate, out startDate)) throw new Exception("Format tanggal mulai tidak valid.");
if (!DateTime.TryParse(req.EndDate, out endDate)) throw new Exception("Format tanggal selesai tidak valid.");
if (endDate < startDate) throw new Exception("Tanggal selesai tidak boleh lebih kecil dari tanggal mulai.");
// ---- Validasi target user ----
string tujuanType = NormalizeTujuanType(req.TujuanType);
string posJoined = null, jobJoined = null;
var manualIds = new List<string>();
if (tujuanType == "PositionJobTitle")
{
posJoined = JoinTargets(req.TargetPositions);
jobJoined = JoinTargets(req.TargetJobTitles);
if (posJoined == null) throw new Exception("Pilih minimal 1 Position.");
if (jobJoined == null) throw new Exception("Pilih minimal 1 Job Title.");
}
else if (tujuanType == "Manual")
{
if (req.ManualDomainIds != null)
{
manualIds = req.ManualDomainIds
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
if (manualIds.Count == 0) throw new Exception("Pilih minimal 1 user.");
}
string tujuanLog = BuildTujuanDisplay(
tujuanType, SplitTargets(posJoined), SplitTargets(jobJoined), manualIds.Count);
string domainId = GetSafeUserId(userId);
using (var conn = new SqlConnection(connString))
{
conn.Open();
using (var trans = conn.BeginTransaction())
{
try
{
string templateName = GetTemplateNameInTransaction(conn, trans, req.PertanyaanID, true);
int agendaId;
if (req.ID == 0)
{
const string insertQuery = @"
INSERT INTO ISEE_PNP_AgendaSurveyNonPNP
(NamaAgenda, PertanyaanID, StartDate, EndDate,
TujuanType, TargetPosition, TargetJobTitle, CreatedBy, CreatedAt)
VALUES
(@namaAgenda, @pertanyaanId, @startDate, @endDate,
@tujuanType, @pos, @job, @userId, GETDATE());
SELECT CAST(SCOPE_IDENTITY() AS INT);";
using (var cmdInsert = new SqlCommand(insertQuery, conn, trans))
{
cmdInsert.Parameters.Add("@namaAgenda", SqlDbType.NVarChar, 500).Value = req.NamaAgenda.Trim();
cmdInsert.Parameters.Add("@pertanyaanId", SqlDbType.Int).Value = req.PertanyaanID;
cmdInsert.Parameters.Add("@startDate", SqlDbType.DateTime).Value = startDate;
cmdInsert.Parameters.Add("@endDate", SqlDbType.DateTime).Value = endDate;
cmdInsert.Parameters.Add("@tujuanType", SqlDbType.NVarChar, 30).Value = tujuanType;
cmdInsert.Parameters.Add("@pos", SqlDbType.NVarChar, -1).Value = (object)posJoined ?? DBNull.Value;
cmdInsert.Parameters.Add("@job", SqlDbType.NVarChar, -1).Value = (object)jobJoined ?? DBNull.Value;
cmdInsert.Parameters.Add("@userId", SqlDbType.NVarChar, 100).Value = domainId;
agendaId = Convert.ToInt32(cmdInsert.ExecuteScalar());
}
InsertHistoryLogInTransaction(conn, trans, domainId,
string.Format(
"Menambahkan Agenda Survey Non PNP '{0}' dengan Template '{1}', Tujuan '{2}'",
req.NamaAgenda, templateName, tujuanLog),
"AgendaSurveyNonPNP", agendaId.ToString());
}
else
{
string oldAgendaName = "";
string oldTemplateName = "";
const string oldQuery = @"
SELECT agenda.NamaAgenda, template.NamaPertanyaan
FROM ISEE_PNP_AgendaSurveyNonPNP agenda
LEFT JOIN ISEE_PNP_PertanyaanSurvey template
ON agenda.PertanyaanID = template.ID
WHERE agenda.ID = @id";
using (var cmdOld = new SqlCommand(oldQuery, conn, trans))
{
cmdOld.Parameters.Add("@id", SqlDbType.Int).Value = req.ID;
using (var reader = cmdOld.ExecuteReader())
{
if (!reader.Read()) throw new Exception("Agenda survey Non PNP tidak ditemukan.");
oldAgendaName = reader.IsDBNull(0) ? "" : Convert.ToString(reader.GetValue(0));
oldTemplateName = reader.IsDBNull(1) ? "" : Convert.ToString(reader.GetValue(1));
}
}
agendaId = req.ID;
const string updateQuery = @"
UPDATE ISEE_PNP_AgendaSurveyNonPNP
SET NamaAgenda = @namaAgenda,
PertanyaanID = @pertanyaanId,
StartDate = @startDate,
EndDate = @endDate,
TujuanType = @tujuanType,
TargetPosition = @pos,
TargetJobTitle = @job,
UpdatedBy = @userId,
UpdatedAt = GETDATE()
WHERE ID = @id";
using (var cmdUpdate = new SqlCommand(updateQuery, conn, trans))
{
cmdUpdate.Parameters.Add("@namaAgenda", SqlDbType.NVarChar, 500).Value = req.NamaAgenda.Trim();
cmdUpdate.Parameters.Add("@pertanyaanId", SqlDbType.Int).Value = req.PertanyaanID;
cmdUpdate.Parameters.Add("@startDate", SqlDbType.DateTime).Value = startDate;
cmdUpdate.Parameters.Add("@endDate", SqlDbType.DateTime).Value = endDate;
cmdUpdate.Parameters.Add("@tujuanType", SqlDbType.NVarChar, 30).Value = tujuanType;
cmdUpdate.Parameters.Add("@pos", SqlDbType.NVarChar, -1).Value = (object)posJoined ?? DBNull.Value;
cmdUpdate.Parameters.Add("@job", SqlDbType.NVarChar, -1).Value = (object)jobJoined ?? DBNull.Value;
cmdUpdate.Parameters.Add("@userId", SqlDbType.NVarChar, 100).Value = domainId;
cmdUpdate.Parameters.Add("@id", SqlDbType.Int).Value = req.ID;
if (cmdUpdate.ExecuteNonQuery() == 0)
throw new Exception("Agenda survey Non PNP gagal diperbarui.");
}
InsertHistoryLogInTransaction(conn, trans, domainId,
string.Format(
"Mengubah Agenda Survey Non PNP dari '{0}' menjadi '{1}', Template dari '{2}' menjadi '{3}', Tujuan '{4}'",
oldAgendaName, req.NamaAgenda, oldTemplateName, templateName, tujuanLog),
"AgendaSurveyNonPNP", req.ID.ToString());
}
// Target manual: selalu disinkronkan (dikosongkan bila bukan Manual)
ReplaceManualTargetsInTransaction(conn, trans, agendaId, manualIds);
trans.Commit();
return agendaId;
}
catch
{
RollbackTransaction(trans);
throw;
}
}
}
}
```
### 2d. `DeleteAgendaNonPNP`
Di dalam transaksi, sebelum `DELETE FROM ISEE_PNP_AgendaSurveyNonPNP`, tambahkan:
```csharp
using (var cmdTarget = new SqlCommand(
"DELETE FROM ISEE_PNP_AgendaSurveyNonPNPTarget WHERE AgendaNonPNPID = @id", conn, trans))
{
cmdTarget.Parameters.Add("@id", SqlDbType.Int).Value = id;
cmdTarget.ExecuteNonQuery();
}
```
---
## 3. UjianPesertaRepository.cs (sisi peserta: ini yang membuat data survey sesuai target)
### 3a. Konstanta filter (taruh di bawah `SqlRemaining`)
```csharp
// Filter target survey Non PNP. Alias tabel agenda harus "a"; wajib ada parameter @domainId.
private const string SqlNonPnpTargetFilter = @"
(
ISNULL(a.TujuanType, 'All') = 'All'
OR (a.TujuanType = 'Manual' AND EXISTS (
SELECT 1 FROM ISEE_PNP_AgendaSurveyNonPNPTarget t
WHERE t.AgendaNonPNPID = a.ID AND t.DomainID = @domainId))
OR (a.TujuanType = 'PositionJobTitle' AND EXISTS (
SELECT 1 FROM v_ISEE_STAFF_DATA s
WHERE s.Domain_ID = @domainId
AND CHARINDEX('|' + LTRIM(RTRIM(s.Position)) + '|', '|' + ISNULL(a.TargetPosition, '') + '|') > 0
AND CHARINDEX('|' + LTRIM(RTRIM(s.Job_Title)) + '|', '|' + ISNULL(a.TargetJobTitle, '') + '|') > 0))
)";
```
### 3b. `GetNonPNPSurveyDashboard`: ganti pembuatan `cmd` pertama
```csharp
var cmd = new SqlCommand(@"SELECT a.ID, a.NamaAgenda, a.StartDate, a.EndDate, a.PertanyaanID
FROM ISEE_PNP_AgendaSurveyNonPNP a
WHERE " + SqlNonPnpTargetFilter + @"
ORDER BY a.StartDate DESC", conn);
cmd.Parameters.AddWithValue("@domainId", domainId);
```
### 3c. `GetSurveyNonPNPByID`: tambah parameter `domainId` dan filter akses
Signature baru: `public SurveyDataModel GetSurveyNonPNPByID(int agendaNonPnpId, string domainId)`, tambahkan `domainId = RequireDomain(domainId);` di baris pertama, lalu ganti query pertamanya:
```csharp
using (var cmd = new SqlCommand(
"SELECT a.ID, a.NamaAgenda, a.PertanyaanID FROM ISEE_PNP_AgendaSurveyNonPNP a WHERE a.ID = @id AND " + SqlNonPnpTargetFilter, conn))
{
cmd.Parameters.AddWithValue("@id", agendaNonPnpId);
cmd.Parameters.AddWithValue("@domainId", domainId);
// ... sisanya tetap
```
Bila user bukan target, `model` tetap `null` dan front-end menampilkan "Survey tidak ditemukan.".
### 3d. `SaveSurveyAnswer`: blokir jawaban dari user yang bukan target
Setelah blok cek kepemilikan `ujianPesertaId` (di dalam `try`, sebelum `object uid = ...`), tambahkan:
```csharp
if (agendaNonPnpId.HasValue && agendaNonPnpId.Value > 0)
{
using (var cmdAcc = new SqlCommand(
"SELECT COUNT(*) FROM ISEE_PNP_AgendaSurveyNonPNP a WHERE a.ID = @anpid AND " + SqlNonPnpTargetFilter, conn, trans))
{
cmdAcc.Parameters.AddWithValue("@anpid", agendaNonPnpId.Value);
cmdAcc.Parameters.AddWithValue("@domainId", domainId);
if (Convert.ToInt32(cmdAcc.ExecuteScalar()) == 0)
throw new ExamException("Survey ini tidak ditujukan untuk Anda.", 403);
}
}
```
---
## 4. UjianPesertaHandler.ashx
```csharp
case "GetNonPNPSurveyByID":
result = repo.GetSurveyNonPNPByID(Convert.ToInt32(context.Request["id"]), domainId);
break;
```
---
## 5. parameter-survey.html
### 5a. Tambahkan CSS (di akhir blok `<style>`)
```css
/* Target user Non PNP */
.field-hint { display: block; margin-top: 4px; color: var(--ink-400, #94A3B8); font-size: 12px; }
.field-error { margin-top: 4px; color: var(--danger, #DC2626); font-size: 12px; }
select.input-error + .select2-container .select2-selection { border-color: var(--danger, #DC2626) !important; }
#nonpnp-modal .modal-body { overflow-x: hidden; }
#nonpnp-modal .select2-container { display: block; width: 100% !important; max-width: 100% !important; }
#nonpnp-modal .select2-container--default .select2-selection--multiple {
min-height: 42px; max-height: 120px; padding: 4px 8px;
overflow-y: auto; overflow-x: hidden; box-sizing: border-box;
}
#nonpnp-modal .select2-selection--multiple .select2-selection__rendered {
display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 0; padding: 0; white-space: normal;
}
#nonpnp-modal .select2-selection--multiple .select2-selection__choice {
margin: 0 !important; max-width: calc(100% - 8px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#nonpnp-modal .select2-selection--multiple .select2-search--inline { flex: 1 1 120px; min-width: 120px; margin: 0; padding: 0; }
#nonpnp-modal .select2-selection--multiple .select2-search__field {
margin: 0 !important; padding: 0 !important; height: 30px !important; line-height: 30px !important; resize: none !important;
}
```
### 5b. Tabel Non PNP: tambah kolom Tujuan
Di `<thead>` tabel `#nonpnp-table`, setelah `<th>Template Survey</th>`:
```html
<th style="width:220px">Tujuan</th>
```
Ubah `colspan="7"` pada baris loading tabel itu menjadi `colspan="8"`.
### 5c. Modal `#nonpnp-modal`: tambahkan blok Tujuan
Letakkan tepat setelah `</div>` penutup `<div class="form-grid" ...>` (Start Date / End Date), sebelum `</div>` penutup `modal-body`:
```html
<div class="form-group">
<label>Tujuan <span style="color:var(--danger)">*</span></label>
<select id="nonpnp-tujuan-type" class="select2">
<option value="All">Semua User</option>
<option value="PositionJobTitle">Berdasarkan Position & Job Title</option>
<option value="Manual">Pilih User Manual</option>
</select>
</div>
<div id="nonpnp-tujuan-posjob-wrap" style="display:none;">
<div class="form-grid" style="grid-template-columns:1fr 1fr;">
<div class="form-group">
<label>Position <span style="color:var(--danger)">*</span></label>
<select id="nonpnp-position" class="select2" multiple="multiple"></select>
<small class="field-hint">Bisa pilih lebih dari satu. User harus cocok Position DAN Job Title.</small>
</div>
<div class="form-group">
<label>Job Title <span style="color:var(--danger)">*</span></label>
<select id="nonpnp-jobtitle" class="select2" multiple="multiple"></select>
<small class="field-hint">Bisa pilih lebih dari satu.</small>
</div>
</div>
</div>
<div id="nonpnp-tujuan-manual-wrap" style="display:none;">
<div class="form-group">
<label>Pilih User <span style="color:var(--danger)">*</span></label>
<select id="nonpnp-manual-users" class="select2" multiple="multiple"></select>
<small class="field-hint">Bisa pilih lebih dari satu user.</small>
</div>
</div>
```
---
## 6. parameter-survey.js
### 6a. Konstanta (di bawah `SV_TTL_DROPDOWN`)
```js
var SV_NOTIF_URL = "/Handlers/pnp/notifikasi/PengaturanNotifikasiHandler.ashx";
var SV_STAFF_URL = "/Handlers/pnp/distribusi-soal/DistribusiSoalHandler.ashx";
```
### 6b. Properti `SurveyApp`
Tambahkan: `_targetDropdownPromise: null,`
Di `init`, setelah `this._initRealtime();` tambahkan:
```js
this._targetDropdownPromise = this._loadTargetDropdowns(token, false);
```
Di `cleanup`, tambahkan: `this._targetDropdownPromise = null;`
### 6c. Tabel Non PNP: kolom Tujuan
`_setupNonPNPTable`: ubah `colspan: 7` menjadi `colspan: 8`.
Di `_renderNonPNPRow`, setelah baris `<td>` Template (yang berisi `item.NamaTemplate`) tambahkan:
```js
html += '<td style="white-space:normal;">' + this._esc(item.TujuanDisplay || "Semua User") + '</td>';
```
dan ubah `colspan="7"` pada baris `history-row` menjadi `colspan="8"`.
Di `_filterNonPNP`, tambahkan `item.TujuanDisplay` ke array nilai pencarian:
```js
item.StartDate,
item.EndDate,
item.TujuanDisplay
```
### 6d. Ganti `_initModalSelect2`
```js
_initModalSelect2: function (modalEl) {
var self = this;
if (typeof $ === "undefined" || !modalEl) return;
var multiIds = ["nonpnp-position", "nonpnp-jobtitle", "nonpnp-manual-users"];
$(modalEl).find(".select2").each(function () {
var $select = $(this);
if ($select.data("select2")) $select.select2("destroy");
var options = { width: "100%", dropdownParent: $(modalEl) };
if (this.id === "nonpnp-tujuan-type") options.minimumResultsForSearch = -1;
if (this.id === "nonpnp-position") { options.placeholder = "Pilih Position"; options.closeOnSelect = false; }
if (this.id === "nonpnp-jobtitle") { options.placeholder = "Pilih Job Title"; options.closeOnSelect = false; }
if (this.id === "nonpnp-manual-users") { options.placeholder = "Pilih user"; options.closeOnSelect = false; }
$select.select2(options);
$select.off(".surveyApp");
if (this.id === "pnp-agenda-distribusi") {
$select.on("select2:select.surveyApp select2:clear.surveyApp", function () { self._onPNPDistribusiChange(this); });
}
if (this.id === "pnp-setting-pertanyaan") {
$select.on("select2:select.surveyApp select2:clear.surveyApp", function () {
self._renderTemplatePreview(this.value, "pnp-setting-template-preview", "pnp-setting-preview-group");
});
}
if (this.id === "pnp-pertanyaan") {
$select.on("select2:select.surveyApp select2:clear.surveyApp", function () {
self._renderTemplatePreview(this.value, "pnp-template-preview", "pnp-preview-group");
});
}
if (this.id === "nonpnp-pertanyaan") {
$select.on("select2:select.surveyApp select2:clear.surveyApp", function () {
self._renderTemplatePreview(this.value, "nonpnp-template-preview", "nonpnp-preview-group");
});
}
if (this.id === "nonpnp-tujuan-type") {
$select.on("change.surveyApp", function () { self._onNonPNPTujuanChange(); });
}
if (multiIds.indexOf(this.id) !== -1) {
$select.on("change.surveyApp", function () { self._clearFieldError(this); });
}
});
},
```
### 6e. Method baru (tambahkan di dalam `SurveyApp`)
```js
_fillOptions: function (id, items, textFn, valueFn) {
var el = document.getElementById(id);
if (!el) return;
var html = "";
(items || []).forEach(function (it) {
html += '<option value="' + this._esc(valueFn(it)) + '">' + this._esc(textFn(it)) + "</option>";
}, this);
el.innerHTML = html;
},
// Position, Job Title & User: sumber dan cache key sama dengan halaman Running Text / Notifikasi.
_loadTargetDropdowns: function (token, force) {
var self = this;
function cached(key, url) {
var fetcher = function () { return self._svSafeFetch(url); };
if (typeof PnpCache !== "undefined") {
return PnpCache.fetchWithCache(key, fetcher, { force: force, ttl: SV_TTL_DROPDOWN });
}
return fetcher();
}
return Promise.all([
cached("nf:position", SV_NOTIF_URL + "?action=GetPositionList"),
cached("nf:jobtitle", SV_NOTIF_URL + "?action=GetJobTitleList"),
cached("nf:staff", SV_STAFF_URL + "?action=GetStaffList")
]).then(function (res) {
if (token !== self._pageToken) return;
self._fillOptions("nonpnp-position", res[0], function (i) { return i.Text; }, function (i) { return i.Value; });
self._fillOptions("nonpnp-jobtitle", res[1], function (i) { return i.Text; }, function (i) { return i.Value; });
self._fillOptions("nonpnp-manual-users", res[2],
function (u) { return u.FullName + " (" + u.DomainID + ")"; },
function (u) { return u.DomainID; });
}).catch(function (e) {
console.error("Gagal memuat dropdown target survey:", e);
});
},
_ensureTargetDropdowns: function () {
return this._targetDropdownPromise || Promise.resolve();
},
_getMulti: function (id) {
if (typeof $ === "undefined") return [];
return $("#" + id).val() || [];
},
_setSelectValue: function (id, value) {
var el = document.getElementById(id);
if (!el) return;
if (typeof $ !== "undefined") $(el).val(value);
else el.value = value;
},
_clearFieldError: function (el) {
if (!el) return;
el.classList.remove("input-error");
var err = el.parentNode && el.parentNode.querySelector(".field-error");
if (err) err.remove();
},
_setNonPNPTujuan: function (type, positions, jobTitles, manualIds) {
this._setSelectValue("nonpnp-tujuan-type", type || "All");
this._setSelectValue("nonpnp-position", positions || []);
this._setSelectValue("nonpnp-jobtitle", jobTitles || []);
this._setSelectValue("nonpnp-manual-users", manualIds || []);
},
_onNonPNPTujuanChange: function () {
var typeEl = document.getElementById("nonpnp-tujuan-type");
var posWrap = document.getElementById("nonpnp-tujuan-posjob-wrap");
var manualWrap = document.getElementById("nonpnp-tujuan-manual-wrap");
if (!typeEl || !posWrap || !manualWrap) return;
var type = typeEl.value;
posWrap.style.display = type === "PositionJobTitle" ? "block" : "none";
manualWrap.style.display = type === "Manual" ? "block" : "none";
if (type !== "PositionJobTitle") {
this._clearFieldError(document.getElementById("nonpnp-position"));
this._clearFieldError(document.getElementById("nonpnp-jobtitle"));
}
if (type !== "Manual") {
this._clearFieldError(document.getElementById("nonpnp-manual-users"));
}
},
```
### 6f. Ganti `openNonPNPSetting`
```js
openNonPNPSetting: function () {
var self = this;
this._ensureTargetDropdowns().then(function () {
self.state.editing.nonPnpId = null;
var titleEl = document.getElementById("nonpnp-modal-title");
if (titleEl) titleEl.innerHTML = window.pnpIcon("cog", 18) + ' Setting Agenda Survey Non PNP';
var editIdEl = document.getElementById("nonpnp-edit-id");
if (editIdEl) editIdEl.value = "";
var namaEl = document.getElementById("nonpnp-nama-agenda");
if (namaEl) namaEl.value = "";
var pertanyaanSel = document.getElementById("nonpnp-pertanyaan");
if (pertanyaanSel) { pertanyaanSel.value = ""; pertanyaanSel.disabled = false; }
var previewGroup = document.getElementById("nonpnp-preview-group");
if (previewGroup) previewGroup.style.display = "none";
self._setLinkedDateValues("nonPnpModalStart", "nonPnpModalEnd", "", "");
self._setNonPNPTujuan("All", [], [], []);
document.querySelectorAll(".nonpnp-edit-hint").forEach(function (el) { el.style.display = "none"; });
self._clearValidationErrors();
self._openModal("nonpnp-modal");
self._onNonPNPTujuanChange();
});
},
```
### 6g. Ganti `editNonPNP`
```js
editNonPNP: function (id) {
var self = this;
var item = this.state.agendaNonPNP.find(function (x) { return x.ID === id; });
if (!item) return;
this._ensureTargetDropdowns().then(function () {
self.state.editing.nonPnpId = id;
var titleEl = document.getElementById("nonpnp-modal-title");
if (titleEl) titleEl.innerHTML = window.pnpIcon("edit", 18) + ' Edit Agenda Survey Non PNP';
var editIdEl = document.getElementById("nonpnp-edit-id");
if (editIdEl) editIdEl.value = id;
var namaEl = document.getElementById("nonpnp-nama-agenda");
if (namaEl) namaEl.value = item.NamaAgenda || "";
var pertanyaanSel = document.getElementById("nonpnp-pertanyaan");
if (pertanyaanSel) { pertanyaanSel.value = item.PertanyaanID || ""; pertanyaanSel.disabled = true; }
self._renderTemplatePreview(item.PertanyaanID, "nonpnp-template-preview", "nonpnp-preview-group");
self._setLinkedDateValues("nonPnpModalStart", "nonPnpModalEnd", item.StartDate, item.EndDate);
self._setNonPNPTujuan(item.TujuanType, item.TargetPositions, item.TargetJobTitles, item.ManualDomainIds);
document.querySelectorAll(".nonpnp-edit-hint").forEach(function (el) { el.style.display = "block"; });
self._clearValidationErrors();
self._openModal("nonpnp-modal");
self._onNonPNPTujuanChange();
});
},
```
### 6h. `validateNonPNPForm`
Sebelum blok `if (errors.length > 0)`, tambahkan:
```js
var tujuanType = document.getElementById("nonpnp-tujuan-type").value;
if (tujuanType === "PositionJobTitle") {
var posEl = document.getElementById("nonpnp-position");
var jobEl = document.getElementById("nonpnp-jobtitle");
if (this._getMulti("nonpnp-position").length === 0) {
this._showFieldError(posEl, "Pilih minimal 1 Position");
errors.push(posEl);
}
if (this._getMulti("nonpnp-jobtitle").length === 0) {
this._showFieldError(jobEl, "Pilih minimal 1 Job Title");
errors.push(jobEl);
}
}
if (tujuanType === "Manual") {
var manualEl = document.getElementById("nonpnp-manual-users");
if (this._getMulti("nonpnp-manual-users").length === 0) {
this._showFieldError(manualEl, "Pilih minimal 1 user");
errors.push(manualEl);
}
}
```
### 6i. `saveNonPNPAgenda`: ganti pembuatan `payload`
```js
var tujuanType = document.getElementById("nonpnp-tujuan-type").value;
var payload = {
ID: isEdit ? this.state.editing.nonPnpId : 0,
NamaAgenda: namaEl ? namaEl.value.trim() : "",
PertanyaanID: pertanyaanEl ? parseInt(pertanyaanEl.value) || 0 : 0,
StartDate: startEl ? startEl.value : "",
EndDate: endEl ? endEl.value : "",
TujuanType: tujuanType,
TargetPositions: tujuanType === "PositionJobTitle" ? this._getMulti("nonpnp-position") : [],
TargetJobTitles: tujuanType === "PositionJobTitle" ? this._getMulti("nonpnp-jobtitle") : [],
ManualDomainIds: tujuanType === "Manual" ? this._getMulti("nonpnp-manual-users") : []
};
```
Pada callback gagal ganti `showToast("Gagal", false)` menjadi `showToast("Gagal: " + ((res && res.error) || "Error"), false)` supaya pesan validasi server terbaca.
---
## Catatan perilaku
- Position dan Job Title bersifat DAN (sama seperti Running Text): user harus cocok keduanya.
- Pencocokan memakai `v_ISEE_STAFF_DATA` (kolom `Position`, `Job_Title`), sama dengan query dashboard ujian.
- Survey yang sudah pernah diisi akan hilang dari dashboard peserta bila target agenda diubah dan peserta tidak lagi termasuk target.
- Dashboard peserta memuat ulang lewat channel realtime `UjianPeserta`. Bila ingin dashboard peserta ikut ter-refresh saat agenda Non PNP diubah, tambahkan `PnpDataHub.Notify("UjianPeserta");` di case `SaveAgendaNonPNP` dan `DeleteAgendaNonPNP` pada ParameterSurveyHandler.ashx.