Cpone-BUG-00024 - fix sinkronisasi status aktif & total peserta project #1

Merged
fajri merged 8 commits from cpone-bug-00024 into main 2026-08-28 16:22:00 +07:00
20 changed files with 1864 additions and 386 deletions

View File

@@ -0,0 +1,283 @@
-- Migration 018: Cpone-BUG-00024
-- Fix sp_generate_dashboard_data:
-- 1. mcu_project sync (Mcu_ProjectTotalParticipant, dst) sempat hilang sejak migration 014, dikembalikan.
-- 2. mcu_patient tidak pernah di-nonaktifkan saat baris sumber (cpone.mcu_preregister_patients)
-- berubah jadi IsActive='N' (mis. duplikat/registrasi ulang) — filter WHERE ...IsActive='Y'
-- menyebabkan baris tsb tidak ikut ter-SELECT sehingga ON DUPLICATE KEY UPDATE tidak
-- pernah menyentuhnya lagi. Filter itu dihapus dan Mcu_PatientIsActive ditambahkan ke
-- ON DUPLICATE KEY UPDATE supaya status aktif selalu mengikuti kondisi sumber terkini.
-- 3. Sumber Mcu_PatientPemeriksaan diperbaiki dari p.Mcu_PreregisterPatientsPemeriksaan
-- (kolom itu tidak ada di cpone.mcu_preregister_patients) menjadi
-- p.Mcu_PreregisterPatientsOrders, yang memang tersedia di skema sumber.
-- 4. Kolom PK di INSERT INTO mcu_checkinout diperbaiki dari Mcu_CheckinoutCheckinoutID
-- (tidak ada) menjadi Mcu_CheckinoutID, sesuai skema cpone_dashboard.mcu_checkinout
-- yang sebenarnya.
-- 5. Mcu_ProjectTotalParticipant tidak lagi ikut m.Mgm_McuTotalParticipant (field target
-- manual di source yang bisa 0/belum diisi), melainkan dihitung langsung dari COUNT
-- baris aktif di cpone.mcu_preregister_patients untuk project tsb.
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
DELIMITER $$
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
BEGIN
-- ----------------------------------------------------------
-- 1. mcu_project
-- ----------------------------------------------------------
INSERT INTO mcu_project (
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
)
SELECT
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
COALESCE(pp.total, 0), NOW()
FROM cpone.mgm_mcu m
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
LEFT JOIN (
SELECT Mcu_PreregisterPatientsMgm_McuID AS mcu_id, COUNT(*) AS total
FROM cpone.mcu_preregister_patients
WHERE Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY Mcu_PreregisterPatientsMgm_McuID
) pp ON pp.mcu_id = m.Mgm_McuID
WHERE m.Mgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
Mcu_ProjectSyncedAt = NOW();
-- ----------------------------------------------------------
-- 2. mcu_patient (with age + pemeriksaan)
-- Tidak lagi memfilter IsActive='Y' di WHERE, supaya baris yang berubah
-- jadi inactive di sumber tetap ke-SELECT dan status-nya ikut ter-update.
-- ----------------------------------------------------------
INSERT INTO mcu_patient (
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB, Mcu_PatientAge,
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
Mcu_PatientPemeriksaan,
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
Mcu_PatientSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
CAST(o.T_OrderHeaderM_PatientAge AS UNSIGNED),
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
p.Mcu_PreregisterPatientsPosisi,
p.Mcu_PreregisterPatientsOrders,
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
p.Mcu_PreregisterPatientsIsActive, NOW()
FROM cpone.mcu_preregister_patients p
LEFT JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_PatientName = VALUES(Mcu_PatientName),
Mcu_PatientAge = VALUES(Mcu_PatientAge),
Mcu_PatientPemeriksaan = VALUES(Mcu_PatientPemeriksaan),
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
Mcu_PatientIsActive = VALUES(Mcu_PatientIsActive),
Mcu_PatientSyncedAt = NOW();
-- ----------------------------------------------------------
-- 3. mcu_patient_schedule
-- ----------------------------------------------------------
INSERT INTO mcu_patient_schedule (
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientScheduleSyncedAt = NOW();
-- ----------------------------------------------------------
-- 4. mcu_checkinout
-- ----------------------------------------------------------
INSERT INTO mcu_checkinout (
Mcu_CheckinoutID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt, Mcu_CheckinoutMcuID
)
SELECT
src.order_id,
src.preregister_id,
src.order_id,
src.segment_date,
TIME(src.checkin_dt),
TIME(src.checkout_dt),
CASE
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
ELSE DATE(src.checkout_dt)
END,
'Y', NOW(), p_mcu_id
FROM (
SELECT
o.T_OrderHeaderID AS order_id,
p.Mcu_PreregisterPatientsID AS preregister_id,
o.T_OrderHeaderDate AS header_dt,
ev.segment_date,
CASE
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
ELSE MIN(ev.event_dt)
END AS checkin_dt,
MAX(ev.event_dt) AS checkout_dt
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN (
SELECT p2.Mcu_PreregisterPatientsID AS preregister_id, o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt, DATE(o2.T_OrderHeaderDate) AS segment_date,
o2.T_OrderHeaderDate AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
UNION ALL
SELECT p2.Mcu_PreregisterPatientsID, o2.T_OrderHeaderID, o2.T_OrderHeaderDate,
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)),
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s2 ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
AND s2.T_OrderSampleIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND s2.T_OrderSampleReceiveDate IS NOT NULL
AND s2.T_OrderSampleReceiveTime IS NOT NULL
UNION ALL
SELECT p2.Mcu_PreregisterPatientsID, o2.T_OrderHeaderID, o2.T_OrderHeaderDate,
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)),
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts2 ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
AND ts2.T_SamplingSoIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND ts2.T_SamplingSoDoneDate IS NOT NULL
AND ts2.T_SamplingSoDoneTime IS NOT NULL
) ev
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
AND ev.order_id = o.T_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
) AS src
ON DUPLICATE KEY UPDATE
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
Mcu_CheckinoutSyncedAt = NOW();
-- ----------------------------------------------------------
-- 5. mcu_station_progress — lab
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, p_mcu_id,
s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, 'lab',
DATE(o.T_OrderHeaderDate),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
NULL, NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 6. mcu_station_progress — nonlab
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, p_mcu_id,
ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, 'nonlab',
DATE(o.T_OrderHeaderDate),
NULL, NULL,
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 7. mcu_patient_resume_status
-- ----------------------------------------------------------
INSERT INTO mcu_patient_resume_status (
Mcu_PatientResumeStatusPreregisterID, Mcu_PatientResumeStatusMcuID,
Mcu_PatientResumeStatusStatus, Mcu_PatientResumeStatusValidated,
Mcu_PatientResumeStatusPublished, Mcu_PatientResumeSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p_mcu_id,
r.Mcu_ResumeStatus, r.Mcu_ResumeValidation, 'N', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
LEFT JOIN cpone.mcu_resume r ON r.Mcu_ResumeT_OrderHeaderID = o.T_OrderHeaderID AND r.Mcu_ResumeIsActive = 'Y'
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientResumeStatusStatus = VALUES(Mcu_PatientResumeStatusStatus),
Mcu_PatientResumeStatusValidated = VALUES(Mcu_PatientResumeStatusValidated),
Mcu_PatientResumeSyncedAt = NOW();
END $$
DELIMITER ;

View File

@@ -0,0 +1,246 @@
-- Migration 019: Cpone-BUG-00024
-- Fix sp_generate_dashboard_data step 4 (mcu_checkinout):
-- Sebelumnya Mcu_CheckinoutOutTime "ditebak" dari MAX(event_dt) gabungan
-- T_OrderHeaderDate, T_OrderSampleReceiveDate/Time, dan T_SamplingSoDoneDate/Time.
-- Ini SALAH karena event2 itu adalah aktivitas lab, bukan checkout fisik pasien —
-- akibatnya pasien yang belum checkout (masih di lokasi) bisa muncul sebagai
-- "Checked Out" di dashboard hanya karena sample-nya sudah diterima/diproses lab.
--
-- Sumber checkout yang benar adalah cpone.preregister_checkin_checkout (lihat
-- komentar kolom di db/migrations/001_init_schema.sql yang memang sudah
-- mendokumentasikan tabel ini sebagai sumber asli mcu_checkinout, tapi belum
-- pernah dipakai oleh stored procedure manapun sebelumnya).
-- PreregisterCheckInCheckOutDate = '0000-00-00' berarti pasien tsb belum checkout.
--
-- Efek samping perbaikan ini: Mcu_CheckinoutID tidak lagi dipaksa = order_id,
-- supaya project multi-hari (1 preregister/order bisa checkin-checkout di
-- beberapa tanggal berbeda) tidak saling bentrok pada primary key. Idempotensi
-- re-sync tetap dijaga oleh UNIQUE KEY uq_checkinout_segment
-- (Mcu_CheckinoutPreregisterID, Mcu_CheckinoutDate, Mcu_CheckinoutInTime).
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
DELIMITER $$
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
BEGIN
-- ----------------------------------------------------------
-- 1. mcu_project
-- ----------------------------------------------------------
INSERT INTO mcu_project (
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
)
SELECT
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
COALESCE(pp.total, 0), NOW()
FROM cpone.mgm_mcu m
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
LEFT JOIN (
SELECT Mcu_PreregisterPatientsMgm_McuID AS mcu_id, COUNT(*) AS total
FROM cpone.mcu_preregister_patients
WHERE Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY Mcu_PreregisterPatientsMgm_McuID
) pp ON pp.mcu_id = m.Mgm_McuID
WHERE m.Mgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
Mcu_ProjectSyncedAt = NOW();
-- ----------------------------------------------------------
-- 2. mcu_patient (with age + pemeriksaan)
-- ----------------------------------------------------------
INSERT INTO mcu_patient (
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB, Mcu_PatientAge,
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
Mcu_PatientPemeriksaan,
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
Mcu_PatientSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
CAST(o.T_OrderHeaderM_PatientAge AS UNSIGNED),
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
p.Mcu_PreregisterPatientsPosisi,
p.Mcu_PreregisterPatientsOrders,
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
p.Mcu_PreregisterPatientsIsActive, NOW()
FROM cpone.mcu_preregister_patients p
LEFT JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_PatientName = VALUES(Mcu_PatientName),
Mcu_PatientAge = VALUES(Mcu_PatientAge),
Mcu_PatientPemeriksaan = VALUES(Mcu_PatientPemeriksaan),
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
Mcu_PatientIsActive = VALUES(Mcu_PatientIsActive),
Mcu_PatientSyncedAt = NOW();
-- ----------------------------------------------------------
-- 3. mcu_patient_schedule
-- ----------------------------------------------------------
INSERT INTO mcu_patient_schedule (
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientScheduleSyncedAt = NOW();
-- ----------------------------------------------------------
-- 4. mcu_checkinout
-- Source: cpone.preregister_checkin_checkout (log checkin/checkout asli
-- dari kiosk/front-desk) — BUKAN ditebak dari event lab.
-- CheckOutDate = '0000-00-00' artinya pasien belum checkout.
-- ----------------------------------------------------------
INSERT INTO mcu_checkinout (
Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
Mcu_CheckinoutOutUserID, Mcu_CheckinoutNextDate, Mcu_CheckinoutNote,
Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt, Mcu_CheckinoutMcuID
)
SELECT
pcc.PreregisterCheckInCheckOutPreregisterID,
pcc.PreregisterCheckInCheckOutT_OrderHeaderID,
pcc.PreregisterCheckInCheckInDate,
pcc.PreregisterCheckInCheckOutInTime,
CASE
WHEN pcc.PreregisterCheckInCheckOutDate = '0000-00-00' THEN NULL
ELSE pcc.PreregisterCheckInCheckOutOutTime
END,
pcc.PreregisterCheckInCheckOutOutUserID,
CASE
WHEN pcc.PreregisterCheckInCheckOutNextPreregisterDate = '0000-00-00' THEN NULL
ELSE pcc.PreregisterCheckInCheckOutNextPreregisterDate
END,
NULLIF(pcc.PreregisterCheckInCheckOutNote, ''),
pcc.PreregisterCheckInCheckOutIsActive,
NOW(),
p_mcu_id
FROM cpone.preregister_checkin_checkout pcc
JOIN cpone.mcu_preregister_patients p
ON p.Mcu_PreregisterPatientsID = pcc.PreregisterCheckInCheckOutPreregisterID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_CheckinoutOrderID = VALUES(Mcu_CheckinoutOrderID),
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
Mcu_CheckinoutOutUserID = VALUES(Mcu_CheckinoutOutUserID),
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
Mcu_CheckinoutNote = VALUES(Mcu_CheckinoutNote),
Mcu_CheckinoutIsActive = VALUES(Mcu_CheckinoutIsActive),
Mcu_CheckinoutSyncedAt = NOW();
-- ----------------------------------------------------------
-- 5. mcu_station_progress — lab
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, p_mcu_id,
s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, 'lab',
DATE(o.T_OrderHeaderDate),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
NULL, NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 6. mcu_station_progress — nonlab
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, p_mcu_id,
ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, 'nonlab',
DATE(o.T_OrderHeaderDate),
NULL, NULL,
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 7. mcu_patient_resume_status
-- ----------------------------------------------------------
INSERT INTO mcu_patient_resume_status (
Mcu_PatientResumeStatusPreregisterID, Mcu_PatientResumeStatusMcuID,
Mcu_PatientResumeStatusStatus, Mcu_PatientResumeStatusValidated,
Mcu_PatientResumeStatusPublished, Mcu_PatientResumeSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p_mcu_id,
r.Mcu_ResumeStatus, r.Mcu_ResumeValidation, 'N', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
LEFT JOIN cpone.mcu_resume r ON r.Mcu_ResumeT_OrderHeaderID = o.T_OrderHeaderID AND r.Mcu_ResumeIsActive = 'Y'
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientResumeStatusStatus = VALUES(Mcu_PatientResumeStatusStatus),
Mcu_PatientResumeStatusValidated = VALUES(Mcu_PatientResumeStatusValidated),
Mcu_PatientResumeSyncedAt = NOW();
END $$
DELIMITER ;

View File

@@ -0,0 +1,247 @@
-- Migration 020: BUG-DASH-280826
-- Fix sp_generate_dashboard_data step 2 (mcu_patient):
-- ON DUPLICATE KEY UPDATE tidak menyertakan Mcu_PatientNIP, Mcu_PatientGender,
-- Mcu_PatientDOB, Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi.
-- Akibatnya begitu satu preregister ID pertama kali ke-sync, kolom2 tsb beku
-- selamanya walau datanya dikoreksi di source (cpone.mcu_preregister_patients) —
-- misalnya NIP pasien yang salah ketik lalu dibetulkan di source tidak pernah
-- ikut terupdate di dashboard.
--
-- Tambahkan kolom2 itu ke ON DUPLICATE KEY UPDATE supaya selalu mengikuti
-- kondisi sumber terkini, konsisten dengan Mcu_PatientName/Mcu_PatientIsActive
-- yang memang sudah di-refresh setiap sync.
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
DELIMITER $$
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
BEGIN
-- ----------------------------------------------------------
-- 1. mcu_project
-- ----------------------------------------------------------
INSERT INTO mcu_project (
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
)
SELECT
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
COALESCE(pp.total, 0), NOW()
FROM cpone.mgm_mcu m
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
LEFT JOIN (
SELECT Mcu_PreregisterPatientsMgm_McuID AS mcu_id, COUNT(*) AS total
FROM cpone.mcu_preregister_patients
WHERE Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY Mcu_PreregisterPatientsMgm_McuID
) pp ON pp.mcu_id = m.Mgm_McuID
WHERE m.Mgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
Mcu_ProjectSyncedAt = NOW();
-- ----------------------------------------------------------
-- 2. mcu_patient (with age + pemeriksaan)
-- Semua kolom demografis (NIP, gender, DOB, department, divisi, posisi)
-- ikut di-refresh setiap sync supaya koreksi di source selalu terbawa.
-- ----------------------------------------------------------
INSERT INTO mcu_patient (
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB, Mcu_PatientAge,
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
Mcu_PatientPemeriksaan,
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
Mcu_PatientSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
CAST(o.T_OrderHeaderM_PatientAge AS UNSIGNED),
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
p.Mcu_PreregisterPatientsPosisi,
p.Mcu_PreregisterPatientsOrders,
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
p.Mcu_PreregisterPatientsIsActive, NOW()
FROM cpone.mcu_preregister_patients p
LEFT JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_PatientName = VALUES(Mcu_PatientName),
Mcu_PatientNIP = VALUES(Mcu_PatientNIP),
Mcu_PatientGender = VALUES(Mcu_PatientGender),
Mcu_PatientDOB = VALUES(Mcu_PatientDOB),
Mcu_PatientAge = VALUES(Mcu_PatientAge),
Mcu_PatientDepartment = VALUES(Mcu_PatientDepartment),
Mcu_PatientDivision = VALUES(Mcu_PatientDivision),
Mcu_PatientPosisi = VALUES(Mcu_PatientPosisi),
Mcu_PatientPemeriksaan = VALUES(Mcu_PatientPemeriksaan),
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
Mcu_PatientIsActive = VALUES(Mcu_PatientIsActive),
Mcu_PatientSyncedAt = NOW();
-- ----------------------------------------------------------
-- 3. mcu_patient_schedule
-- ----------------------------------------------------------
INSERT INTO mcu_patient_schedule (
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientScheduleSyncedAt = NOW();
-- ----------------------------------------------------------
-- 4. mcu_checkinout
-- Source: cpone.preregister_checkin_checkout (log checkin/checkout asli
-- dari kiosk/front-desk) — BUKAN ditebak dari event lab.
-- CheckOutDate = '0000-00-00' artinya pasien belum checkout.
-- ----------------------------------------------------------
INSERT INTO mcu_checkinout (
Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
Mcu_CheckinoutOutUserID, Mcu_CheckinoutNextDate, Mcu_CheckinoutNote,
Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt, Mcu_CheckinoutMcuID
)
SELECT
pcc.PreregisterCheckInCheckOutPreregisterID,
pcc.PreregisterCheckInCheckOutT_OrderHeaderID,
pcc.PreregisterCheckInCheckInDate,
pcc.PreregisterCheckInCheckOutInTime,
CASE
WHEN pcc.PreregisterCheckInCheckOutDate = '0000-00-00' THEN NULL
ELSE pcc.PreregisterCheckInCheckOutOutTime
END,
pcc.PreregisterCheckInCheckOutOutUserID,
CASE
WHEN pcc.PreregisterCheckInCheckOutNextPreregisterDate = '0000-00-00' THEN NULL
ELSE pcc.PreregisterCheckInCheckOutNextPreregisterDate
END,
NULLIF(pcc.PreregisterCheckInCheckOutNote, ''),
pcc.PreregisterCheckInCheckOutIsActive,
NOW(),
p_mcu_id
FROM cpone.preregister_checkin_checkout pcc
JOIN cpone.mcu_preregister_patients p
ON p.Mcu_PreregisterPatientsID = pcc.PreregisterCheckInCheckOutPreregisterID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_CheckinoutOrderID = VALUES(Mcu_CheckinoutOrderID),
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
Mcu_CheckinoutOutUserID = VALUES(Mcu_CheckinoutOutUserID),
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
Mcu_CheckinoutNote = VALUES(Mcu_CheckinoutNote),
Mcu_CheckinoutIsActive = VALUES(Mcu_CheckinoutIsActive),
Mcu_CheckinoutSyncedAt = NOW();
-- ----------------------------------------------------------
-- 5. mcu_station_progress — lab
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, p_mcu_id,
s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, 'lab',
DATE(o.T_OrderHeaderDate),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
NULL, NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 6. mcu_station_progress — nonlab
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, p_mcu_id,
ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, 'nonlab',
DATE(o.T_OrderHeaderDate),
NULL, NULL,
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 7. mcu_patient_resume_status
-- ----------------------------------------------------------
INSERT INTO mcu_patient_resume_status (
Mcu_PatientResumeStatusPreregisterID, Mcu_PatientResumeStatusMcuID,
Mcu_PatientResumeStatusStatus, Mcu_PatientResumeStatusValidated,
Mcu_PatientResumeStatusPublished, Mcu_PatientResumeSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p_mcu_id,
r.Mcu_ResumeStatus, r.Mcu_ResumeValidation, 'N', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
LEFT JOIN cpone.mcu_resume r ON r.Mcu_ResumeT_OrderHeaderID = o.T_OrderHeaderID AND r.Mcu_ResumeIsActive = 'Y'
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientResumeStatusStatus = VALUES(Mcu_PatientResumeStatusStatus),
Mcu_PatientResumeStatusValidated = VALUES(Mcu_PatientResumeStatusValidated),
Mcu_PatientResumeSyncedAt = NOW();
END $$
DELIMITER ;

View File

@@ -24,13 +24,16 @@ type pageData struct {
AvailableDates []string AvailableDates []string
Search string Search string
Department string Department string
Paket string
Rows []ArrivalRow Rows []ArrivalRow
FilteredRows []ArrivalRow FilteredRows []ArrivalRow
Summary ArrivalSummary Summary ArrivalSummary
Departments []DepartmentStat Departments []DepartmentStat
DepartmentOptions []string DepartmentOptions []string
PaketOptions []PaketOption
OverviewJSON template.JS OverviewJSON template.JS
DepartmentJSON template.JS DepartmentJSON template.JS
PaketChartJSON template.JS
Page int Page int
PageSize int PageSize int
HasMore bool HasMore bool
@@ -76,15 +79,27 @@ func buildArrivalData(r *http.Request) (pageData, int, error) {
return pageData{}, http.StatusInternalServerError, err return pageData{}, http.StatusInternalServerError, err
} }
summary, deptStats := BuildArrivalStats(rows) summary, deptStats := BuildArrivalStats(rows)
filteredRows := FilterArrivalRows(rows, r.URL.Query().Get("search"), r.URL.Query().Get("dept")) filteredRows := FilterArrivalRows(rows, r.URL.Query().Get("search"), r.URL.Query().Get("dept"), r.URL.Query().Get("paket"))
deptOptions := UniqueDepartments(rows) deptOptions := UniqueDepartments(rows)
paketOptions := UniquePakets(rows)
stationMap, err := GetStationProgress(mcuID, selectedDate) stationMap, err := GetStationProgress(mcuID, selectedDate)
if err != nil { if err != nil {
return pageData{}, http.StatusInternalServerError, err return pageData{}, http.StatusInternalServerError, err
} }
for i := range filteredRows { for i := range filteredRows {
filteredRows[i].Stations = stationMap[filteredRows[i].PreregisterID] stations := stationMap[filteredRows[i].PreregisterID]
filteredRows[i].Stations = stations
for _, s := range stations {
switch s.Tone {
case "success":
filteredRows[i].DoneCount++
case "warning":
filteredRows[i].ProgressCount++
default:
filteredRows[i].PendingCount++
}
}
} }
outerDepts := []map[string]any{} outerDepts := []map[string]any{}
@@ -111,6 +126,26 @@ func buildArrivalData(r *http.Request) (pageData, int, error) {
"counts": stationCounts, "counts": stationCounts,
} }
paketStats, err := GetPaketStats(mcuID, selectedDate)
if err != nil {
return pageData{}, http.StatusInternalServerError, err
}
paketLabels := make([]string, len(paketStats))
paketTotal := make([]int, len(paketStats))
paketDone := make([]int, len(paketStats))
paketRemaining := make([]int, len(paketStats))
for i, p := range paketStats {
paketLabels[i] = p.Name
paketTotal[i] = p.Total
paketDone[i] = p.Completed
paketRemaining[i] = p.Total - p.Completed
}
paketChart := map[string]any{
"labels": paketLabels,
"done": paketDone,
"remaining": paketRemaining,
}
page := parsePage(r.URL.Query().Get("page")) page := parsePage(r.URL.Query().Get("page"))
pagedRows, hasMore := PaginateArrivalRows(filteredRows, page, defaultPageSize) pagedRows, hasMore := PaginateArrivalRows(filteredRows, page, defaultPageSize)
visibleCount := page * defaultPageSize visibleCount := page * defaultPageSize
@@ -125,13 +160,16 @@ func buildArrivalData(r *http.Request) (pageData, int, error) {
AvailableDates: dates, AvailableDates: dates,
Search: r.URL.Query().Get("search"), Search: r.URL.Query().Get("search"),
Department: r.URL.Query().Get("dept"), Department: r.URL.Query().Get("dept"),
Paket: r.URL.Query().Get("paket"),
Rows: rows, Rows: rows,
FilteredRows: pagedRows, FilteredRows: pagedRows,
Summary: summary, Summary: summary,
Departments: deptStats, Departments: deptStats,
DepartmentOptions: deptOptions, DepartmentOptions: deptOptions,
PaketOptions: paketOptions,
OverviewJSON: toJS(overview), OverviewJSON: toJS(overview),
DepartmentJSON: toJS(stationChart), DepartmentJSON: toJS(stationChart),
PaketChartJSON: toJS(paketChart),
Page: page, Page: page,
PageSize: defaultPageSize, PageSize: defaultPageSize,
HasMore: hasMore, HasMore: hasMore,

View File

@@ -13,6 +13,12 @@ type StationBadge struct {
Tone string // "success" | "warning" | "danger" | "neutral" Tone string // "success" | "warning" | "danger" | "neutral"
} }
type PaketStat struct {
Name string
Total int
Completed int
}
type StationStat struct { type StationStat struct {
Name string Name string
Count int Count int
@@ -23,10 +29,16 @@ type ArrivalRow struct {
NIP string NIP string
Name string Name string
Department string Department string
Pemeriksaan string
PaketCode string
PaketName string
InTime string InTime string
Status string Status string
StatusTone string StatusTone string
Stations []StationBadge Stations []StationBadge
DoneCount int
ProgressCount int
PendingCount int
} }
type DepartmentStat struct { type DepartmentStat struct {
@@ -83,6 +95,17 @@ func GetArrivalRows(mcuID int, date string) ([]ArrivalRow, error) {
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''), NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
'-' '-'
) AS department_name, ) AS department_name,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientPemeriksaan), ''), '') AS pemeriksaan,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(pp.Mcu_PatientPacketCode), ''), pp.Mcu_PatientPacketName) ORDER BY COALESCE(NULLIF(TRIM(pp.Mcu_PatientPacketCode), ''), pp.Mcu_PatientPacketName) SEPARATOR ',')
FROM mcu_patient_packet pp
WHERE pp.Mcu_PatientPacketPreregisterID = mp.Mcu_PatientPreregisterID
), '') AS paket_code,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT pp.Mcu_PatientPacketName ORDER BY pp.Mcu_PatientPacketName SEPARATOR ', ')
FROM mcu_patient_packet pp
WHERE pp.Mcu_PatientPacketPreregisterID = mp.Mcu_PatientPreregisterID
), '') AS paket_name,
COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'), '') AS in_time, COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'), '') AS in_time,
CASE CASE
WHEN mc.Mcu_CheckinoutOutTime IS NOT NULL THEN 'Performed' WHEN mc.Mcu_CheckinoutOutTime IS NOT NULL THEN 'Performed'
@@ -119,7 +142,7 @@ func GetArrivalRows(mcuID int, date string) ([]ArrivalRow, error) {
var result []ArrivalRow var result []ArrivalRow
for rows.Next() { for rows.Next() {
var r ArrivalRow var r ArrivalRow
if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Department, &r.InTime, &r.Status, &r.StatusTone); err != nil { if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Department, &r.Pemeriksaan, &r.PaketCode, &r.PaketName, &r.InTime, &r.Status, &r.StatusTone); err != nil {
continue continue
} }
if strings.TrimSpace(r.NIP) == "" { if strings.TrimSpace(r.NIP) == "" {
@@ -139,31 +162,59 @@ func GetArrivalRows(mcuID int, date string) ([]ArrivalRow, error) {
func GetStationProgress(mcuID int, date string) (map[int][]StationBadge, error) { func GetStationProgress(mcuID int, date string) (map[int][]StationBadge, error) {
rows, err := db.DB.Query(` rows, err := db.DB.Query(`
SELECT SELECT
sp.Mcu_StationProgressPreregisterID, rs.preregister_id,
sp.Mcu_StationProgressStationName, rs.station_name,
CASE CASE
WHEN MAX(CASE
WHEN sp.Mcu_StationProgressSource = 'lab' WHEN sp.Mcu_StationProgressSource = 'lab'
AND sp.Mcu_StationProgressReceiveAt IS NOT NULL THEN 'success' AND sp.Mcu_StationProgressReceiveAt IS NOT NULL
AND DATE(sp.Mcu_StationProgressReceiveAt) <= mc.Mcu_CheckinoutDate THEN 1
WHEN sp.Mcu_StationProgressSource = 'nonlab' WHEN sp.Mcu_StationProgressSource = 'nonlab'
AND sp.Mcu_StationProgressDoneAt IS NOT NULL THEN 'success' AND sp.Mcu_StationProgressProcessAt IS NOT NULL
AND DATE(sp.Mcu_StationProgressProcessAt) <= mc.Mcu_CheckinoutDate THEN 1
WHEN sp.Mcu_StationProgressSource = 'nonlab'
AND sp.Mcu_StationProgressDoneAt IS NOT NULL
AND DATE(sp.Mcu_StationProgressDoneAt) <= mc.Mcu_CheckinoutDate THEN 1
ELSE 0
END) = 1 THEN 'success'
WHEN MAX(CASE
WHEN sp.Mcu_StationProgressProcessAt IS NOT NULL WHEN sp.Mcu_StationProgressProcessAt IS NOT NULL
OR sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressProcessAt) <= mc.Mcu_CheckinoutDate THEN 1
OR sp.Mcu_StationProgressSamplingAt IS NOT NULL THEN 'warning' WHEN sp.Mcu_StationProgressReceiveAt IS NOT NULL
AND DATE(sp.Mcu_StationProgressReceiveAt) <= mc.Mcu_CheckinoutDate THEN 1
WHEN sp.Mcu_StationProgressSamplingAt IS NOT NULL
AND DATE(sp.Mcu_StationProgressSamplingAt) <= mc.Mcu_CheckinoutDate THEN 1
ELSE 0
END) = 1 THEN 'warning'
ELSE 'neutral' ELSE 'neutral'
END AS tone END AS tone
FROM mcu_station_progress sp FROM mcu_patient_required_station rs
WHERE sp.Mcu_StationProgressMcuID = ? JOIN mcu_checkinout mc
AND sp.Mcu_StationProgressPreregisterID IN ( ON mc.Mcu_CheckinoutPreregisterID = rs.preregister_id
SELECT mp.Mcu_PatientPreregisterID AND mc.Mcu_CheckinoutMcuID = rs.mcu_id
FROM mcu_patient_schedule s AND mc.Mcu_CheckinoutDate = ?
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID AND mc.Mcu_CheckinoutIsActive = 'Y'
WHERE mp.Mcu_PatientMcuID = ? LEFT JOIN mcu_station_progress sp
AND mp.Mcu_PatientIsActive = 'Y' ON sp.Mcu_StationProgressPreregisterID = rs.preregister_id
AND s.Mcu_PatientScheduleDate = ? AND sp.Mcu_StationProgressStationID = rs.sample_station_id
AND s.Mcu_PatientScheduleIsActive = 'Y' AND sp.Mcu_StationProgressMcuID = rs.mcu_id
AND (
(sp.Mcu_StationProgressCheckinDate IS NOT NULL
AND sp.Mcu_StationProgressCheckinDate <= mc.Mcu_CheckinoutDate)
OR
(sp.Mcu_StationProgressCheckinDate IS NULL
AND (
(sp.Mcu_StationProgressProcessAt IS NOT NULL AND DATE(sp.Mcu_StationProgressProcessAt) <= mc.Mcu_CheckinoutDate)
OR (sp.Mcu_StationProgressDoneAt IS NOT NULL AND DATE(sp.Mcu_StationProgressDoneAt) <= mc.Mcu_CheckinoutDate)
OR (sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressReceiveAt) <= mc.Mcu_CheckinoutDate)
OR (sp.Mcu_StationProgressSamplingAt IS NOT NULL AND DATE(sp.Mcu_StationProgressSamplingAt) <= mc.Mcu_CheckinoutDate)
) )
ORDER BY sp.Mcu_StationProgressPreregisterID, sp.Mcu_StationProgressStationName )
`, mcuID, mcuID, date) )
WHERE rs.mcu_id = ?
GROUP BY rs.preregister_id, rs.station_name
ORDER BY rs.preregister_id, rs.station_name
`, date, mcuID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -247,16 +298,20 @@ func BuildStationChart(stationMap map[int][]StationBadge) []StationStat {
return stats return stats
} }
func FilterArrivalRows(rows []ArrivalRow, search, dept string) []ArrivalRow { func FilterArrivalRows(rows []ArrivalRow, search, dept, paket string) []ArrivalRow {
search = strings.ToLower(strings.TrimSpace(search)) search = strings.ToLower(strings.TrimSpace(search))
dept = strings.TrimSpace(dept) dept = strings.TrimSpace(dept)
if search == "" && dept == "" { paket = strings.TrimSpace(paket)
if search == "" && dept == "" && paket == "" {
return rows return rows
} }
out := make([]ArrivalRow, 0, len(rows)) out := make([]ArrivalRow, 0, len(rows))
for _, row := range rows { for _, row := range rows {
if dept != "" && dept != "All Departments" && row.Department != dept { if dept != "" && row.Department != dept {
continue
}
if paket != "" && row.PaketCode != paket {
continue continue
} }
if search != "" { if search != "" {
@@ -270,6 +325,33 @@ func FilterArrivalRows(rows []ArrivalRow, search, dept string) []ArrivalRow {
return out return out
} }
type PaketOption struct {
Code string
Name string
}
func UniquePakets(rows []ArrivalRow) []PaketOption {
seen := map[string]struct{}{}
var out []PaketOption
for _, row := range rows {
code := strings.TrimSpace(row.PaketCode)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
name := strings.TrimSpace(row.PaketName)
if name == "" {
name = code
}
out = append(out, PaketOption{Code: code, Name: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
return out
}
func PaginateArrivalRows(rows []ArrivalRow, page, pageSize int) ([]ArrivalRow, bool) { func PaginateArrivalRows(rows []ArrivalRow, page, pageSize int) ([]ArrivalRow, bool) {
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -306,6 +388,64 @@ func UniqueDepartments(rows []ArrivalRow) []string {
return out return out
} }
func GetPaketStats(mcuID int, date string) ([]PaketStat, error) {
rows, err := db.DB.Query(`
SELECT
pp.Mcu_PatientPacketName,
COUNT(DISTINCT s.Mcu_PatientSchedulePreregisterID) AS total,
COUNT(DISTINCT CASE
WHEN st.total_required > 0 AND st.total_required = st.total_done
THEN s.Mcu_PatientSchedulePreregisterID
END) AS completed
FROM mcu_patient_packet pp
JOIN mcu_patient_schedule s
ON s.Mcu_PatientSchedulePreregisterID = pp.Mcu_PatientPacketPreregisterID
AND s.Mcu_PatientScheduleDate = ?
AND s.Mcu_PatientScheduleIsActive = 'Y'
JOIN mcu_patient mp
ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID
AND mp.Mcu_PatientMcuID = ?
AND mp.Mcu_PatientIsActive = 'Y'
LEFT JOIN (
SELECT
rs.preregister_id,
COUNT(rs.sample_station_id) AS total_required,
SUM(CASE
WHEN sp.Mcu_StationProgressSource = 'lab'
AND sp.Mcu_StationProgressReceiveAt IS NOT NULL THEN 1
WHEN sp.Mcu_StationProgressSource = 'nonlab'
AND sp.Mcu_StationProgressProcessAt IS NOT NULL THEN 1
WHEN sp.Mcu_StationProgressSource = 'nonlab'
AND sp.Mcu_StationProgressDoneAt IS NOT NULL THEN 1
ELSE 0
END) AS total_done
FROM mcu_patient_required_station rs
LEFT JOIN mcu_station_progress sp
ON sp.Mcu_StationProgressPreregisterID = rs.preregister_id
AND sp.Mcu_StationProgressStationID = rs.sample_station_id
AND sp.Mcu_StationProgressMcuID = rs.mcu_id
WHERE rs.mcu_id = ?
GROUP BY rs.preregister_id
) st ON st.preregister_id = s.Mcu_PatientSchedulePreregisterID
GROUP BY pp.Mcu_PatientPacketName
ORDER BY total DESC
`, date, mcuID, mcuID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []PaketStat
for rows.Next() {
var p PaketStat
if err := rows.Scan(&p.Name, &p.Total, &p.Completed); err != nil {
continue
}
result = append(result, p)
}
return result, nil
}
func activeDateOrLatest(dates []string, selected string, fallback string) string { func activeDateOrLatest(dates []string, selected string, fallback string) string {
selected = strings.TrimSpace(selected) selected = strings.TrimSpace(selected)
if selected != "" { if selected != "" {

View File

@@ -9,6 +9,7 @@ import (
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -28,6 +29,13 @@ type ArrivalsPartial struct {
type PatientsPartial struct { type PatientsPartial struct {
Patients []PatientDetail Patients []PatientDetail
IsRange bool IsRange bool
Search string
ProgressStatus string
KpiFilter string
Page int
PageSize int
HasMore bool
NextPage int
} }
type PageData struct { type PageData struct {
@@ -237,12 +245,84 @@ func Patients(w http.ResponseWriter, r *http.Request) {
http.Error(w, "query error", http.StatusInternalServerError) http.Error(w, "query error", http.StatusInternalServerError)
return return
} }
search := strings.TrimSpace(r.URL.Query().Get("search"))
progressStatus := strings.TrimSpace(r.URL.Query().Get("progress_status"))
kpiFilter := strings.TrimSpace(r.URL.Query().Get("kpi_filter"))
if kpiFilter != "" {
filteredByKPI := make([]PatientDetail, 0, len(patients))
for _, p := range patients {
switch kpiFilter {
case "invited":
filteredByKPI = append(filteredByKPI, p)
case "total_staff":
if p.InTime != "" {
filteredByKPI = append(filteredByKPI, p)
}
case "inprogress":
if p.InTime != "" && !(p.HasOut && p.ProgressStatus == "done") {
filteredByKPI = append(filteredByKPI, p)
}
case "checkedout":
if p.HasOut && p.ProgressStatus == "done" {
filteredByKPI = append(filteredByKPI, p)
}
default:
filteredByKPI = append(filteredByKPI, p)
}
}
patients = filteredByKPI
}
if search != "" || progressStatus != "" {
lowerSearch := strings.ToLower(search)
filtered := make([]PatientDetail, 0, len(patients))
for _, p := range patients {
if progressStatus != "" && p.ProgressStatus != progressStatus {
continue
}
if lowerSearch != "" && !strings.Contains(strings.ToLower(p.Name), lowerSearch) && !strings.Contains(strconv.Itoa(p.ID), lowerSearch) {
continue
}
filtered = append(filtered, p)
}
patients = filtered
}
page := 1
if p, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("page"))); err == nil && p > 0 {
page = p
}
pageSize := 30
start := (page - 1) * pageSize
if start > len(patients) {
start = len(patients)
}
end := start + pageSize
if end > len(patients) {
end = len(patients)
}
pagedPatients := patients[start:end]
hasMore := end < len(patients)
data := PatientsPartial{ data := PatientsPartial{
Patients: patients, Patients: pagedPatients,
IsRange: mode != "daily" || dateFrom != dateTo, IsRange: mode != "daily" || dateFrom != dateTo,
Search: search,
ProgressStatus: progressStatus,
KpiFilter: kpiFilter,
Page: page,
PageSize: pageSize,
HasMore: hasMore,
NextPage: page + 1,
} }
t := parse("templates/dashboard/partials/patients.html") t := parse("templates/dashboard/partials/patients.html")
if err := t.ExecuteTemplate(w, "patients", data); err != nil { templateName := "patients"
if r.URL.Query().Get("chunk") == "1" {
templateName = "patients-rows"
}
if err := t.ExecuteTemplate(w, templateName, data); err != nil {
log.Printf("[dashboard] patients template error: %v", err) log.Printf("[dashboard] patients template error: %v", err)
} }
} }

View File

@@ -93,31 +93,25 @@ func GetKPI(mcuID int, dateFrom, dateTo string) (KPIData, error) {
AND Mcu_ParticipantDailyIsActive = 'Y' AND Mcu_ParticipantDailyIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.InvitedStaff) `, mcuID, dateFrom, dateTo).Scan(&d.InvitedStaff)
// Total staff: semua yang datang (checkin) pada tanggal filter // Total staff / checked-in / checked-out dihitung dari sumber yang sama dengan
db.DB.QueryRow(` // detail pasien (GetAllPatients), supaya KPI card dan modal detail selalu sinkron.
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout // "Checked Out" hanya terhitung kalau pasien SUDAH checkout DAN semua station
WHERE Mcu_CheckinoutMcuID = ? // wajibnya sudah selesai — checkout fisik saja tidak cukup.
AND Mcu_CheckinoutDate BETWEEN ? AND ? patients, err := GetAllPatients(mcuID, dateFrom, dateTo)
AND Mcu_CheckinoutIsActive = 'Y' if err != nil {
`, mcuID, dateFrom, dateTo).Scan(&d.TotalStaff) return d, err
}
// Checked-in: masih di dalam (belum checkout) for _, p := range patients {
db.DB.QueryRow(` if p.InTime == "" {
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout continue
WHERE Mcu_CheckinoutMcuID = ? }
AND Mcu_CheckinoutDate BETWEEN ? AND ? d.TotalStaff++
AND Mcu_CheckinoutOutTime IS NULL if p.HasOut && p.ProgressStatus == "done" {
AND Mcu_CheckinoutIsActive = 'Y' d.CheckedOut++
`, mcuID, dateFrom, dateTo).Scan(&d.CheckedIn) } else {
d.CheckedIn++
// Checked-out: sudah selesai }
db.DB.QueryRow(` }
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.CheckedOut)
return d, nil return d, nil
} }
@@ -391,6 +385,7 @@ func GetPeriodTrend(mcuID int, dateFrom, dateTo string, isRange bool) ([]TrendPo
type PatientStationStatus struct { type PatientStationStatus struct {
Station string Station string
Done bool Done bool
Progress bool
ProcessAt string ProcessAt string
DoneAt string DoneAt string
} }
@@ -404,6 +399,8 @@ type PatientDetail struct {
HasOut bool HasOut bool
Stations []PatientStationStatus Stations []PatientStationStatus
DoneCount int DoneCount int
ProgressCount int
ProgressStatus string
} }
type patientKey struct { type patientKey struct {
@@ -414,16 +411,22 @@ type patientKey struct {
func GetAllPatients(mcuID int, dateFrom, dateTo string) ([]PatientDetail, error) { func GetAllPatients(mcuID int, dateFrom, dateTo string) ([]PatientDetail, error) {
rows, err := db.DB.Query(` rows, err := db.DB.Query(`
SELECT SELECT
mc.Mcu_CheckinoutPreregisterID, s.Mcu_PatientSchedulePreregisterID,
mp.Mcu_PatientName, mp.Mcu_PatientName,
DATE_FORMAT(mc.Mcu_CheckinoutDate, '%Y-%m-%d'), DATE_FORMAT(s.Mcu_PatientScheduleDate, '%Y-%m-%d'),
DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'), COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'), ''),
COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutOutTime, '%H:%i'), ''), COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutOutTime, '%H:%i'), ''),
IF(mc.Mcu_CheckinoutOutTime IS NOT NULL, 1, 0), IF(mc.Mcu_CheckinoutOutTime IS NOT NULL, 1, 0),
rs.station_name, COALESCE(rs.station_name, ''),
IF( IF(
(sp.Mcu_StationProgressSource = 'lab' AND sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressReceiveAt) <= mc.Mcu_CheckinoutDate) (sp.Mcu_StationProgressSource = 'lab' AND sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressReceiveAt) <= s.Mcu_PatientScheduleDate)
OR (sp.Mcu_StationProgressSource = 'nonlab' AND sp.Mcu_StationProgressDoneAt IS NOT NULL AND DATE(sp.Mcu_StationProgressDoneAt) <= mc.Mcu_CheckinoutDate), OR (sp.Mcu_StationProgressSource = 'nonlab' AND sp.Mcu_StationProgressProcessAt IS NOT NULL AND DATE(sp.Mcu_StationProgressProcessAt) <= s.Mcu_PatientScheduleDate)
OR (sp.Mcu_StationProgressSource = 'nonlab' AND sp.Mcu_StationProgressDoneAt IS NOT NULL AND DATE(sp.Mcu_StationProgressDoneAt) <= s.Mcu_PatientScheduleDate),
1, 0),
IF(
(sp.Mcu_StationProgressProcessAt IS NOT NULL AND DATE(sp.Mcu_StationProgressProcessAt) <= s.Mcu_PatientScheduleDate)
OR (sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressReceiveAt) <= s.Mcu_PatientScheduleDate)
OR (sp.Mcu_StationProgressSamplingAt IS NOT NULL AND DATE(sp.Mcu_StationProgressSamplingAt) <= s.Mcu_PatientScheduleDate),
1, 0), 1, 0),
COALESCE(DATE_FORMAT( COALESCE(DATE_FORMAT(
CASE CASE
@@ -435,29 +438,41 @@ func GetAllPatients(mcuID int, dateFrom, dateTo string) ([]PatientDetail, error)
END, END,
'%d/%m/%Y %H:%i'), ''), '%d/%m/%Y %H:%i'), ''),
COALESCE(DATE_FORMAT( COALESCE(DATE_FORMAT(
IF(sp.Mcu_StationProgressSource = 'lab', sp.Mcu_StationProgressReceiveAt, sp.Mcu_StationProgressDoneAt), CASE
WHEN sp.Mcu_StationProgressSource = 'lab' THEN sp.Mcu_StationProgressReceiveAt
WHEN sp.Mcu_StationProgressSource = 'nonlab' THEN COALESCE(sp.Mcu_StationProgressDoneAt, sp.Mcu_StationProgressProcessAt)
ELSE NULL
END,
'%d/%m/%Y %H:%i'), '') '%d/%m/%Y %H:%i'), '')
FROM mcu_checkinout mc FROM mcu_patient_schedule s
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID
JOIN mcu_patient_required_station rs ON AND mp.Mcu_PatientMcuID = ?
rs.preregister_id = mc.Mcu_CheckinoutPreregisterID AND mp.Mcu_PatientIsActive = 'Y'
LEFT JOIN mcu_checkinout mc ON
mc.Mcu_CheckinoutPreregisterID = s.Mcu_PatientSchedulePreregisterID
AND mc.Mcu_CheckinoutMcuID = ?
AND mc.Mcu_CheckinoutDate = s.Mcu_PatientScheduleDate
AND mc.Mcu_CheckinoutIsActive = 'Y'
LEFT JOIN mcu_patient_required_station rs ON
rs.preregister_id = s.Mcu_PatientSchedulePreregisterID
AND rs.mcu_id = ? AND rs.mcu_id = ?
LEFT JOIN mcu_station_progress sp ON LEFT JOIN mcu_station_progress sp ON
sp.Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID sp.Mcu_StationProgressPreregisterID = s.Mcu_PatientSchedulePreregisterID
AND sp.Mcu_StationProgressStationID = rs.sample_station_id AND sp.Mcu_StationProgressStationID = rs.sample_station_id
AND sp.Mcu_StationProgressMcuID = ? AND sp.Mcu_StationProgressMcuID = ?
AND sp.Mcu_StationProgressCheckinDate = ( AND (
SELECT MAX(sp2.Mcu_StationProgressCheckinDate) (sp.Mcu_StationProgressCheckinDate IS NOT NULL AND sp.Mcu_StationProgressCheckinDate <= s.Mcu_PatientScheduleDate)
FROM mcu_station_progress sp2 OR
WHERE sp2.Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID (sp.Mcu_StationProgressCheckinDate IS NULL AND (
AND sp2.Mcu_StationProgressStationID = rs.sample_station_id (sp.Mcu_StationProgressDoneAt IS NOT NULL AND DATE(sp.Mcu_StationProgressDoneAt) <= s.Mcu_PatientScheduleDate)
AND sp2.Mcu_StationProgressMcuID = ? OR (sp.Mcu_StationProgressProcessAt IS NOT NULL AND DATE(sp.Mcu_StationProgressProcessAt) <= s.Mcu_PatientScheduleDate)
AND sp2.Mcu_StationProgressCheckinDate <= mc.Mcu_CheckinoutDate OR (sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressReceiveAt) <= s.Mcu_PatientScheduleDate)
OR (sp.Mcu_StationProgressSamplingAt IS NOT NULL AND DATE(sp.Mcu_StationProgressSamplingAt) <= s.Mcu_PatientScheduleDate)
))
) )
WHERE mc.Mcu_CheckinoutMcuID = ? WHERE s.Mcu_PatientScheduleDate BETWEEN ? AND ?
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ? AND s.Mcu_PatientScheduleIsActive = 'Y'
AND mc.Mcu_CheckinoutIsActive = 'Y' ORDER BY s.Mcu_PatientScheduleDate DESC, mc.Mcu_CheckinoutInTime DESC, mp.Mcu_PatientName ASC, rs.station_name
ORDER BY mc.Mcu_CheckinoutDate DESC, mc.Mcu_CheckinoutInTime DESC, rs.station_name
`, mcuID, mcuID, mcuID, mcuID, dateFrom, dateTo) `, mcuID, mcuID, mcuID, mcuID, dateFrom, dateTo)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -470,8 +485,8 @@ func GetAllPatients(mcuID int, dateFrom, dateTo string) ([]PatientDetail, error)
for rows.Next() { for rows.Next() {
var pid int var pid int
var name, date, inTime, outTime, stationName, processAt, doneAt string var name, date, inTime, outTime, stationName, processAt, doneAt string
var hasOutInt, doneInt int var hasOutInt, doneInt, progressInt int
if err := rows.Scan(&pid, &name, &date, &inTime, &outTime, &hasOutInt, &stationName, &doneInt, &processAt, &doneAt); err != nil { if err := rows.Scan(&pid, &name, &date, &inTime, &outTime, &hasOutInt, &stationName, &doneInt, &progressInt, &processAt, &doneAt); err != nil {
continue continue
} }
k := patientKey{ID: pid, Date: date} k := patientKey{ID: pid, Date: date}
@@ -489,16 +504,34 @@ func GetAllPatients(mcuID int, dateFrom, dateTo string) ([]PatientDetail, error)
patients = append(patients, p) patients = append(patients, p)
idx = len(patients) - 1 idx = len(patients) - 1
} }
if stationName != "" {
done := doneInt == 1 done := doneInt == 1
patients[idx].Stations = append(patients[idx].Stations, PatientStationStatus{ patients[idx].Stations = append(patients[idx].Stations, PatientStationStatus{
Station: stationName, Station: stationName,
Done: done, Done: done,
Progress: progressInt == 1,
ProcessAt: processAt, ProcessAt: processAt,
DoneAt: doneAt, DoneAt: doneAt,
}) })
if done { if done {
patients[idx].DoneCount++ patients[idx].DoneCount++
} }
if progressInt == 1 {
patients[idx].ProgressCount++
}
}
}
for i := range patients {
totalStations := len(patients[i].Stations)
switch {
case totalStations > 0 && patients[i].DoneCount == totalStations:
patients[i].ProgressStatus = "done"
case patients[i].DoneCount > 0 || patients[i].ProgressCount > 0:
patients[i].ProgressStatus = "partial"
default:
patients[i].ProgressStatus = "not_yet"
}
} }
return patients, nil return patients, nil

View File

@@ -21,6 +21,8 @@ type pageData struct {
CurrentProject projects.ProjectItem CurrentProject projects.ProjectItem
Search string Search string
Status string Status string
Packet string
PacketOptions []PacketOption
Rows []ProgressRow Rows []ProgressRow
FilteredRows []ProgressRow FilteredRows []ProgressRow
Summary ProgressSummary Summary ProgressSummary
@@ -64,7 +66,9 @@ func buildProgressData(r *http.Request) (pageData, int, error) {
summary := BuildProgressSummary(rows) summary := BuildProgressSummary(rows)
search := r.URL.Query().Get("search") search := r.URL.Query().Get("search")
status := r.URL.Query().Get("status") status := r.URL.Query().Get("status")
filteredRows := FilterProgressRows(rows, search, status) packet := r.URL.Query().Get("packet")
packetOptions := UniquePackets(rows)
filteredRows := FilterProgressRows(rows, search, status, packet)
page := parsePage(r.URL.Query().Get("page")) page := parsePage(r.URL.Query().Get("page"))
pagedRows, hasMore := PaginateProgressRows(filteredRows, page, defaultPageSize) pagedRows, hasMore := PaginateProgressRows(filteredRows, page, defaultPageSize)
visibleCount := page * defaultPageSize visibleCount := page * defaultPageSize
@@ -77,6 +81,8 @@ func buildProgressData(r *http.Request) (pageData, int, error) {
CurrentProject: project, CurrentProject: project,
Search: search, Search: search,
Status: status, Status: status,
Packet: packet,
PacketOptions: packetOptions,
Rows: rows, Rows: rows,
FilteredRows: pagedRows, FilteredRows: pagedRows,
Summary: summary, Summary: summary,

View File

@@ -10,6 +10,9 @@ type ProgressRow struct {
NIP string NIP string
Name string Name string
Posisi string Posisi string
PacketCode string
PacketName string
Tests string
ResumeStatus string ResumeStatus string
Validated string Validated string
Published string Published string
@@ -33,6 +36,17 @@ func GetProgressRows(mcuID int) ([]ProgressRow, error) {
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''), NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
'-' '-'
) AS posisi, ) AS posisi,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(pp.Mcu_PatientPacketCode), ''), pp.Mcu_PatientPacketName) ORDER BY COALESCE(NULLIF(TRIM(pp.Mcu_PatientPacketCode), ''), pp.Mcu_PatientPacketName) SEPARATOR ',')
FROM mcu_patient_packet pp
WHERE pp.Mcu_PatientPacketPreregisterID = mp.Mcu_PatientPreregisterID
), '') AS packet_code,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT pp.Mcu_PatientPacketName ORDER BY pp.Mcu_PatientPacketName SEPARATOR ', ')
FROM mcu_patient_packet pp
WHERE pp.Mcu_PatientPacketPreregisterID = mp.Mcu_PatientPreregisterID
), '') AS packet_name,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientPemeriksaan), ''), '') AS tests,
COALESCE(rs.Mcu_PatientResumeStatusStatus, '') AS resume_status, COALESCE(rs.Mcu_PatientResumeStatusStatus, '') AS resume_status,
COALESCE(rs.Mcu_PatientResumeStatusValidated, 'N') AS validated, COALESCE(rs.Mcu_PatientResumeStatusValidated, 'N') AS validated,
COALESCE(rs.Mcu_PatientResumeStatusPublished, 'N') AS published COALESCE(rs.Mcu_PatientResumeStatusPublished, 'N') AS published
@@ -54,7 +68,7 @@ func GetProgressRows(mcuID int) ([]ProgressRow, error) {
var result []ProgressRow var result []ProgressRow
for rows.Next() { for rows.Next() {
var r ProgressRow var r ProgressRow
if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Posisi, &r.ResumeStatus, &r.Validated, &r.Published); err != nil { if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Posisi, &r.PacketCode, &r.PacketName, &r.Tests, &r.ResumeStatus, &r.Validated, &r.Published); err != nil {
continue continue
} }
result = append(result, r) result = append(result, r)
@@ -75,14 +89,18 @@ func BuildProgressSummary(rows []ProgressRow) ProgressSummary {
return s return s
} }
func FilterProgressRows(rows []ProgressRow, search, status string) []ProgressRow { func FilterProgressRows(rows []ProgressRow, search, status, packetCode string) []ProgressRow {
search = strings.ToLower(strings.TrimSpace(search)) search = strings.ToLower(strings.TrimSpace(search))
status = strings.TrimSpace(status) status = strings.TrimSpace(status)
if search == "" && status == "" { packetCode = strings.TrimSpace(packetCode)
if search == "" && status == "" && packetCode == "" {
return rows return rows
} }
out := make([]ProgressRow, 0, len(rows)) out := make([]ProgressRow, 0, len(rows))
for _, r := range rows { for _, r := range rows {
if packetCode != "" && r.PacketCode != packetCode {
continue
}
switch status { switch status {
case "validated": case "validated":
if r.Validated != "Y" { if r.Validated != "Y" {
@@ -102,7 +120,7 @@ func FilterProgressRows(rows []ProgressRow, search, status string) []ProgressRow
} }
} }
if search != "" { if search != "" {
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi) hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi + " " + r.PacketCode + " " + r.PacketName + " " + r.Tests)
if !strings.Contains(hay, search) { if !strings.Contains(hay, search) {
continue continue
} }
@@ -112,6 +130,40 @@ func FilterProgressRows(rows []ProgressRow, search, status string) []ProgressRow
return out return out
} }
type PacketOption struct {
Code string
Name string
}
func UniquePackets(rows []ProgressRow) []PacketOption {
seen := map[string]struct{}{}
out := []PacketOption{}
for _, r := range rows {
code := strings.TrimSpace(r.PacketCode)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
name := strings.TrimSpace(r.PacketName)
if name == "" {
name = code
}
out = append(out, PacketOption{Code: code, Name: name})
}
// Keep deterministic order without new imports.
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Code < out[i].Code {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
func Pct(num, total int) int { func Pct(num, total int) int {
if total == 0 { if total == 0 {
return 0 return 0

View File

@@ -23,6 +23,8 @@ type pageData struct {
CurrentProject projects.ProjectItem CurrentProject projects.ProjectItem
Search string Search string
Filter string Filter string
Packet string
PacketOptions []PacketOption
Rows []ResultRow Rows []ResultRow
FilteredRows []ResultRow FilteredRows []ResultRow
Summary ResultSummary Summary ResultSummary
@@ -65,7 +67,9 @@ func buildResultData(r *http.Request) (pageData, int, error) {
summary := BuildResultSummary(rows) summary := BuildResultSummary(rows)
search := r.URL.Query().Get("search") search := r.URL.Query().Get("search")
filter := r.URL.Query().Get("filter") filter := r.URL.Query().Get("filter")
filteredRows := FilterResultRows(rows, search, filter) packet := r.URL.Query().Get("packet")
packetOptions := UniquePackets(rows)
filteredRows := FilterResultRows(rows, search, filter, packet)
page := parsePage(r.URL.Query().Get("page")) page := parsePage(r.URL.Query().Get("page"))
pagedRows, hasMore := PaginateResultRows(filteredRows, page, defaultPageSize) pagedRows, hasMore := PaginateResultRows(filteredRows, page, defaultPageSize)
visibleCount := page * defaultPageSize visibleCount := page * defaultPageSize
@@ -78,6 +82,8 @@ func buildResultData(r *http.Request) (pageData, int, error) {
CurrentProject: project, CurrentProject: project,
Search: search, Search: search,
Filter: filter, Filter: filter,
Packet: packet,
PacketOptions: packetOptions,
Rows: rows, Rows: rows,
FilteredRows: pagedRows, FilteredRows: pagedRows,
Summary: summary, Summary: summary,

View File

@@ -9,8 +9,12 @@ type ResultRow struct {
NIP string NIP string
Name string Name string
Posisi string Posisi string
PacketCode string
PacketName string
Tests string
FileUrl string FileUrl string
ReportDate string ReportDate string
ReportVersion string
} }
type ResultSummary struct { type ResultSummary struct {
@@ -29,13 +33,30 @@ func GetResultRows(mcuID int) ([]ResultRow, error) {
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''), NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
'-' '-'
) AS posisi, ) AS posisi,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(pp.Mcu_PatientPacketCode), ''), pp.Mcu_PatientPacketName) ORDER BY COALESCE(NULLIF(TRIM(pp.Mcu_PatientPacketCode), ''), pp.Mcu_PatientPacketName) SEPARATOR ',')
FROM mcu_patient_packet pp
WHERE pp.Mcu_PatientPacketPreregisterID = mp.Mcu_PatientPreregisterID
), '') AS packet_code,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT pp.Mcu_PatientPacketName ORDER BY pp.Mcu_PatientPacketName SEPARATOR ', ')
FROM mcu_patient_packet pp
WHERE pp.Mcu_PatientPacketPreregisterID = mp.Mcu_PatientPreregisterID
), '') AS packet_name,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientPemeriksaan), ''), '') AS tests,
COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url, COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url,
CASE CASE
WHEN p.Published_McuDasboardFileUrl IS NOT NULL WHEN p.Published_McuDasboardFileUrl IS NOT NULL
AND p.Published_McuDasboardFileUrl != '' AND p.Published_McuDasboardFileUrl != ''
THEN COALESCE(CAST(p.Published_McuDasboardLastUpdated AS CHAR), '') THEN COALESCE(CAST(p.Published_McuDasboardLastUpdated AS CHAR), '')
ELSE '' ELSE ''
END AS report_date END AS report_date,
CASE
WHEN p.Published_McuDasboardFileUrl IS NOT NULL
AND p.Published_McuDasboardFileUrl != ''
THEN COALESCE(CAST(UNIX_TIMESTAMP(p.Published_McuDasboardLastUpdated) AS CHAR), '')
ELSE ''
END AS report_version
FROM mcu_patient mp FROM mcu_patient mp
LEFT JOIN published_mcu_dashboard_sync p LEFT JOIN published_mcu_dashboard_sync p
ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID
@@ -53,7 +74,7 @@ func GetResultRows(mcuID int) ([]ResultRow, error) {
var result []ResultRow var result []ResultRow
for rows.Next() { for rows.Next() {
var r ResultRow var r ResultRow
if err := rows.Scan(&r.NIP, &r.Name, &r.Posisi, &r.FileUrl, &r.ReportDate); err != nil { if err := rows.Scan(&r.NIP, &r.Name, &r.Posisi, &r.PacketCode, &r.PacketName, &r.Tests, &r.FileUrl, &r.ReportDate, &r.ReportVersion); err != nil {
continue continue
} }
result = append(result, r) result = append(result, r)
@@ -71,14 +92,18 @@ func BuildResultSummary(rows []ResultRow) ResultSummary {
return s return s
} }
func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow { func FilterResultRows(rows []ResultRow, search, filter, packetCode string) []ResultRow {
search = strings.ToLower(strings.TrimSpace(search)) search = strings.ToLower(strings.TrimSpace(search))
filter = strings.TrimSpace(filter) filter = strings.TrimSpace(filter)
if search == "" && filter == "" { packetCode = strings.TrimSpace(packetCode)
if search == "" && filter == "" && packetCode == "" {
return rows return rows
} }
out := make([]ResultRow, 0, len(rows)) out := make([]ResultRow, 0, len(rows))
for _, r := range rows { for _, r := range rows {
if packetCode != "" && r.PacketCode != packetCode {
continue
}
switch filter { switch filter {
case "has_pdf": case "has_pdf":
if r.FileUrl == "" { if r.FileUrl == "" {
@@ -90,7 +115,7 @@ func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow {
} }
} }
if search != "" { if search != "" {
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi) hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi + " " + r.PacketCode + " " + r.PacketName + " " + r.Tests)
if !strings.Contains(hay, search) { if !strings.Contains(hay, search) {
continue continue
} }
@@ -100,6 +125,39 @@ func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow {
return out return out
} }
type PacketOption struct {
Code string
Name string
}
func UniquePackets(rows []ResultRow) []PacketOption {
seen := map[string]struct{}{}
out := []PacketOption{}
for _, r := range rows {
code := strings.TrimSpace(r.PacketCode)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
name := strings.TrimSpace(r.PacketName)
if name == "" {
name = code
}
out = append(out, PacketOption{Code: code, Name: name})
}
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Code < out[i].Code {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
func PaginateResultRows(rows []ResultRow, page, pageSize int) ([]ResultRow, bool) { func PaginateResultRows(rows []ResultRow, page, pageSize int) ([]ResultRow, bool) {
if page < 1 { if page < 1 {
page = 1 page = 1

View File

@@ -18,7 +18,7 @@
</p> </p>
</div> </div>
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600"> <a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project Change Project
</a> </a>
</div> </div>
</section> </section>

View File

@@ -18,20 +18,21 @@
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600"> <a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project Change Project
</a> </a>
<form method="get" action="{{b "/arrival"}}" class="flex flex-wrap items-center gap-2"> <form method="get" action="{{b "/arrival"}}" class="flex flex-wrap items-center gap-2">
<input type="hidden" name="search" value="{{.Search}}"/> <input type="hidden" name="search" value="{{.Search}}"/>
<input type="hidden" name="dept" value="{{.Department}}"/> <input type="hidden" name="dept" value="{{.Department}}"/>
<label class="text-xs font-medium text-slate-500">Tanggal Check-in</label> <label class="text-xs font-medium text-slate-500">Check-in Date</label>
<select name="date" <select name="date"
onchange="this.form.submit()"
class="num rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-slate-700 focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200"> class="num rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-slate-700 focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200">
{{range .AvailableDates}} {{range .AvailableDates}}
<option value="{{.}}" {{if eq . $.Date}}selected{{end}}>{{. | fmtDate}}</option> <option value="{{.}}" {{if eq . $.Date}}selected{{end}}>{{. | fmtDate}}</option>
{{end}} {{end}}
</select> </select>
<button type="submit" class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600"> <button type="submit" class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
Lihat View
</button> </button>
</form> </form>
</div> </div>
@@ -74,26 +75,44 @@
</article> </article>
</section> </section>
<section class="card p-4"> <section class="card p-5">
<form id="arrival-filter-form" method="get" action="{{b "/arrival"}}" class="grid gap-3 md:grid-cols-3"> <div class="mb-3">
<input type="hidden" name="date" value="{{.Date}}"/> <p class="text-sm font-semibold text-slate-700">Per Paket Completion</p>
<div class="md:col-span-2"> <p class="text-xs text-slate-400">Total pasien per paket vs sudah menyelesaikan semua station</p>
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Participant</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Name or Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div> </div>
<div> <div id="paket-distribution-chart" class="h-72 w-full"></div>
<label for="dept" class="mb-2 block text-sm font-medium text-slate-600">Department</label> </section>
<section class="card p-4">
<form id="arrival-filter-form" method="get" action="{{b "/arrival"}}" class="flex flex-wrap items-end gap-2">
<input type="hidden" name="date" value="{{.Date}}"/>
<div class="min-w-[160px] flex-1">
<label for="search" class="mb-1 block text-xs font-medium text-slate-500">Search</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Name or Employee ID"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div class="min-w-[160px] flex-1">
<label for="paket" class="mb-1 block text-xs font-medium text-slate-500">Paket</label>
<select id="paket" name="paket"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Paket ""}}selected{{end}}>All Paket</option>
{{range .PaketOptions}}
<option value="{{.Code}}" {{if eq .Code $.Paket}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="min-w-[140px] flex-1">
<label for="dept" class="mb-1 block text-xs font-medium text-slate-500">Department</label>
<select id="dept" name="dept" <select id="dept" name="dept"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"> class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Department ""}}selected{{end}}>All Departments</option> <option value="" {{if eq .Department ""}}selected{{end}}>All Departments</option>
{{range .DepartmentOptions}} {{range .DepartmentOptions}}
<option value="{{.}}" {{if eq . $.Department}}selected{{end}}>{{.}}</option> <option value="{{.}}" {{if eq . $.Department}}selected{{end}}>{{.}}</option>
{{end}} {{end}}
</select> </select>
</div> </div>
<div class="md:col-span-3 flex justify-end"> <div class="shrink-0">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600"> <button type="submit" class="rounded-lg bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter Filter
</button> </button>
</div> </div>
@@ -109,13 +128,6 @@
<span class="text-xs font-medium text-slate-400">{{.VisibleCount}} / {{.TotalFiltered}} ditampilkan</span> <span class="text-xs font-medium text-slate-400">{{.VisibleCount}} / {{.TotalFiltered}} ditampilkan</span>
</div> </div>
<div class="border-b border-slate-100 px-5 py-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Performed Yet</span>
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 font-medium text-amber-700">In Progress</span>
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 font-medium text-emerald-700">Performed</span>
</div>
</div>
{{if .FilteredRows}} {{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block"> <div class="hidden overflow-x-auto md:block">
@@ -126,6 +138,7 @@
<th class="px-4 py-3 font-medium">Employee ID</th> <th class="px-4 py-3 font-medium">Employee ID</th>
<th class="px-4 py-3 font-medium">Name</th> <th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">Department</th> <th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Tests</th>
<th class="px-4 py-3 font-medium">Status</th> <th class="px-4 py-3 font-medium">Status</th>
</tr> </tr>
</thead> </thead>
@@ -136,21 +149,30 @@
<td class="px-4 py-3 num">{{.NIP}}</td> <td class="px-4 py-3 num">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td> <td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Department}}</td> <td class="px-4 py-3 text-slate-500">{{.Department}}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-[160px]">
{{if .Pemeriksaan}}<span title="{{.Pemeriksaan}}" class="line-clamp-2">{{.Pemeriksaan}}</span>{{else}}-{{end}}
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{if .Stations}} {{if not .InTime}}
<div class="flex flex-wrap gap-1.5"> <span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Not Check-In Yet</span>
{{else if .Stations}}
<details class="group">
<summary class="list-none cursor-pointer">
<div class="flex flex-wrap gap-1.5 items-center">
{{if gt .DoneCount 0}}<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Done ({{.DoneCount}})</span>{{end}}
{{if gt .ProgressCount 0}}<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">In Progress ({{.ProgressCount}})</span>{{end}}
{{if gt .PendingCount 0}}<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Pending ({{.PendingCount}})</span>{{end}}
<svg class="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</div>
</summary>
<div class="mt-2 flex flex-wrap gap-1.5">
{{range .Stations}} {{range .Stations}}
{{if eq .Tone "success"}} {{if eq .Tone "success"}}<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span> {{else if eq .Tone "warning"}}<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "warning"}} {{else}}<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>{{end}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "danger"}}
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
{{end}}
{{end}} {{end}}
</div> </div>
</details>
{{else}} {{else}}
{{if eq .StatusTone "success"}} {{if eq .StatusTone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span> <span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
@@ -172,61 +194,48 @@
<article class="rounded-xl border border-slate-200 p-3"> <article class="rounded-xl border border-slate-200 p-3">
<p class="font-semibold text-slate-700">{{.Name}}</p> <p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p> <p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p>
<div class="mt-3 flex flex-col gap-1.5"> {{if .Pemeriksaan}}<p class="mt-1 text-xs text-slate-500 line-clamp-1">{{.Pemeriksaan}}</p>{{end}}
{{if .Stations}} <div class="mt-3">
{{if not .InTime}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Not Check-In Yet</span>
{{else if .Stations}}
<details class="group">
<summary class="list-none cursor-pointer">
<div class="flex flex-wrap gap-1.5 items-center">
{{if gt .DoneCount 0}}<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Done ({{.DoneCount}})</span>{{end}}
{{if gt .ProgressCount 0}}<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">In Progress ({{.ProgressCount}})</span>{{end}}
{{if gt .PendingCount 0}}<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Pending ({{.PendingCount}})</span>{{end}}
<svg class="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</div>
</summary>
<div class="mt-2 flex flex-col gap-1.5">
{{range .Stations}} {{range .Stations}}
{{if eq .Tone "success"}} {{if eq .Tone "success"}}
<span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700"> <span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span class="truncate">{{.Name}}</span> <span class="truncate">{{.Name}}</span>
</span> </span>
{{else if eq .Tone "warning"}} {{else if eq .Tone "warning"}}
<span class="flex w-full items-center gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700"> <span class="flex w-full items-center gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Name}}</span>
</span>
{{else if eq .Tone "danger"}}
<span class="flex w-full items-center gap-2 border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-medium text-rose-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Name}}</span> <span class="truncate">{{.Name}}</span>
</span> </span>
{{else}} {{else}}
<span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600"> <span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Name}}</span> <span class="truncate">{{.Name}}</span>
</span> </span>
{{end}} {{end}}
{{end}} {{end}}
</div>
</details>
{{else}} {{else}}
{{if eq .StatusTone "success"}} {{if eq .StatusTone "success"}}
<span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700"> <span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span class="truncate">{{.Status}}</span>
</span>
{{else if eq .StatusTone "warning"}} {{else if eq .StatusTone "warning"}}
<span class="flex w-full items-center gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700"> <span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Status}}</span>
</span>
{{else}} {{else}}
<span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600"> <span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Status}}</span>
</span>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
@@ -236,7 +245,7 @@
<div id="arrival-load-more" class="px-4 pb-4"> <div id="arrival-load-more" class="px-4 pb-4">
{{if .HasMore}} {{if .HasMore}}
<div <div
hx-get='{{b "/arrival/list"}}?date={{.Date}}&search={{.Search}}&dept={{.Department}}&page={{.NextPage}}' hx-get='{{b "/arrival/list"}}?date={{urlquery .Date}}&search={{urlquery .Search}}&paket={{urlquery .Paket}}&dept={{urlquery .Department}}&page={{.NextPage}}'
hx-trigger="revealed" hx-trigger="revealed"
hx-swap="outerHTML" hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500"> class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
@@ -274,6 +283,7 @@
const overviewData = {{.OverviewJSON}}; const overviewData = {{.OverviewJSON}};
const stationData = {{.DepartmentJSON}}; const stationData = {{.DepartmentJSON}};
const paketData = {{.PaketChartJSON}};
const deptColors = ['#f59e0b', '#8b5cf6', '#f97316', '#06b6d4', '#ec4899', '#84cc16', '#14b8a6']; const deptColors = ['#f59e0b', '#8b5cf6', '#f97316', '#06b6d4', '#ec4899', '#84cc16', '#14b8a6'];
const overviewEl = document.getElementById('arrival-overview-chart'); const overviewEl = document.getElementById('arrival-overview-chart');
@@ -307,7 +317,7 @@
show: !isMobile, show: !isMobile,
type: 'scroll', type: 'scroll',
orient: 'vertical', orient: 'vertical',
left: '58%', left: '68%',
right: 0, right: 0,
top: 'middle', top: 'middle',
textStyle: { color: '#64748b', fontSize: 11 }, textStyle: { color: '#64748b', fontSize: 11 },
@@ -321,7 +331,7 @@
name: 'Check-in Summary', name: 'Check-in Summary',
type: 'pie', type: 'pie',
radius: ['28%', '45%'], radius: ['28%', '45%'],
center: isMobile ? ['50%', '50%'] : ['38%', '48%'], center: isMobile ? ['50%', '50%'] : ['30%', '48%'],
label: { show: false }, label: { show: false },
labelLine: { show: false }, labelLine: { show: false },
data: [ data: [
@@ -333,7 +343,7 @@
name: 'Dept Detail', name: 'Dept Detail',
type: 'pie', type: 'pie',
radius: ['55%', '72%'], radius: ['55%', '72%'],
center: isMobile ? ['50%', '50%'] : ['38%', '48%'], center: isMobile ? ['50%', '50%'] : ['30%', '48%'],
label: { show: false }, label: { show: false },
labelLine: { show: false }, labelLine: { show: false },
data: outerData data: outerData
@@ -370,6 +380,63 @@
}); });
window.addEventListener('resize', () => stationChart.resize()); window.addEventListener('resize', () => stationChart.resize());
} }
const paketEl = document.getElementById('paket-distribution-chart');
if (paketEl && paketData && typeof echarts !== 'undefined') {
const paketChart = echarts.init(paketEl);
var paketLabels = (paketData.labels || []).slice().reverse();
var paketDone = (paketData.done || []).slice().reverse();
var paketRemaining = (paketData.remaining || []).slice().reverse();
paketChart.setOption({
color: ['#10b981', '#cbd5e1'],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: function (params) {
var done = 0;
var remaining = 0;
for (var i = 0; i < params.length; i++) {
if (params[i].seriesName === 'Completed') done = params[i].value || 0;
if (params[i].seriesName === 'Not Completed') remaining = params[i].value || 0;
}
var total = done + remaining;
return params[0].name + '<br/>Total: ' + total + '<br/>Completed: ' + done + '<br/>Not Completed: ' + remaining;
}
},
legend: {
bottom: 0,
textStyle: { color: '#64748b', fontSize: 11 }
},
grid: { left: 10, right: 20, top: 10, bottom: 32, containLabel: true },
xAxis: {
type: 'value',
axisLabel: { color: '#64748b' }
},
yAxis: {
type: 'category',
axisLabel: { color: '#64748b' },
data: paketLabels
},
series: [
{
name: 'Completed',
type: 'bar',
stack: 'total',
barWidth: 18,
data: paketDone
},
{
name: 'Not Completed',
type: 'bar',
stack: 'total',
barWidth: 18,
data: paketRemaining,
itemStyle: { borderRadius: [0, 6, 6, 0] }
}
]
});
window.addEventListener('resize', () => paketChart.resize());
}
})(); })();
</script> </script>
{{end}} {{end}}
@@ -382,21 +449,30 @@
<td class="px-4 py-3 num">{{.NIP}}</td> <td class="px-4 py-3 num">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td> <td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Department}}</td> <td class="px-4 py-3 text-slate-500">{{.Department}}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-[160px]">
{{if .Pemeriksaan}}<span title="{{.Pemeriksaan}}" class="line-clamp-2">{{.Pemeriksaan}}</span>{{else}}-{{end}}
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{if .Stations}} {{if not .InTime}}
<div class="flex flex-wrap gap-1.5"> <span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Not Check-In Yet</span>
{{else if .Stations}}
<details class="group">
<summary class="list-none cursor-pointer">
<div class="flex flex-wrap gap-1.5 items-center">
{{if gt .DoneCount 0}}<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Done ({{.DoneCount}})</span>{{end}}
{{if gt .ProgressCount 0}}<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">In Progress ({{.ProgressCount}})</span>{{end}}
{{if gt .PendingCount 0}}<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Pending ({{.PendingCount}})</span>{{end}}
<svg class="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</div>
</summary>
<div class="mt-2 flex flex-wrap gap-1.5">
{{range .Stations}} {{range .Stations}}
{{if eq .Tone "success"}} {{if eq .Tone "success"}}<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span> {{else if eq .Tone "warning"}}<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "warning"}} {{else}}<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>{{end}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "danger"}}
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
{{end}}
{{end}} {{end}}
</div> </div>
</details>
{{else}} {{else}}
{{if eq .StatusTone "success"}} {{if eq .StatusTone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span> <span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
@@ -411,13 +487,26 @@
{{end}} {{end}}
</tbody> </tbody>
<div id="arrival-mobile-rows" hx-swap-oob="beforeend"> <div id="arrival-mobile-rows" hx-swap-oob="beforeend" style="display:contents">
{{range .FilteredRows}} {{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3"> <article class="rounded-xl border border-slate-200 p-3 md:hidden">
<p class="font-semibold text-slate-700">{{.Name}}</p> <p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p> <p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p>
<div class="mt-3 flex flex-col gap-1.5"> {{if .Pemeriksaan}}<p class="mt-1 text-xs text-slate-500 line-clamp-1">{{.Pemeriksaan}}</p>{{end}}
{{if .Stations}} <div class="mt-3">
{{if not .InTime}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Not Check-In Yet</span>
{{else if .Stations}}
<details class="group">
<summary class="list-none cursor-pointer">
<div class="flex flex-wrap gap-1.5 items-center">
{{if gt .DoneCount 0}}<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Done ({{.DoneCount}})</span>{{end}}
{{if gt .ProgressCount 0}}<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">In Progress ({{.ProgressCount}})</span>{{end}}
{{if gt .PendingCount 0}}<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Pending ({{.PendingCount}})</span>{{end}}
<svg class="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</div>
</summary>
<div class="mt-2 flex flex-col gap-1.5">
{{range .Stations}} {{range .Stations}}
{{if eq .Tone "success"}} {{if eq .Tone "success"}}
<span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700"> <span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700">
@@ -429,11 +518,6 @@
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Name}}</span> <span class="truncate">{{.Name}}</span>
</span> </span>
{{else if eq .Tone "danger"}}
<span class="flex w-full items-center gap-2 border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-medium text-rose-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Name}}</span>
</span>
{{else}} {{else}}
<span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600"> <span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
@@ -441,22 +525,15 @@
</span> </span>
{{end}} {{end}}
{{end}} {{end}}
</div>
</details>
{{else}} {{else}}
{{if eq .StatusTone "success"}} {{if eq .StatusTone "success"}}
<span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700"> <span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<span class="truncate">{{.Status}}</span>
</span>
{{else if eq .StatusTone "warning"}} {{else if eq .StatusTone "warning"}}
<span class="flex w-full items-center gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700"> <span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Status}}</span>
</span>
{{else}} {{else}}
<span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600"> <span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Status}}</span>
</span>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
@@ -467,7 +544,7 @@
<div id="arrival-load-more" hx-swap-oob="outerHTML"> <div id="arrival-load-more" hx-swap-oob="outerHTML">
{{if .HasMore}} {{if .HasMore}}
<div <div
hx-get='{{b "/arrival/list"}}?date={{.Date}}&search={{.Search}}&dept={{.Department}}&page={{.NextPage}}' hx-get='{{b "/arrival/list"}}?date={{urlquery .Date}}&search={{urlquery .Search}}&paket={{urlquery .Paket}}&dept={{urlquery .Department}}&page={{.NextPage}}'
hx-trigger="revealed" hx-trigger="revealed"
hx-swap="outerHTML" hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500"> class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">

View File

@@ -162,7 +162,7 @@
</style> </style>
<!-- SSE wrapper — satu koneksi, semua section dapat update otomatis --> <!-- SSE wrapper — satu koneksi, semua section dapat update otomatis -->
<div hx-ext="sse" sse-connect="{{b "/dashboard/stream"}}?mode=daily&date={{.DateFrom}}"> <div hx-ext="sse" sse-connect="{{b "/dashboard/stream"}}?mode=daily&date={{.DateFrom}}&mcu_id={{.McuID}}">
<!-- Project Banner --> <!-- Project Banner -->
<section class="card p-5"> <section class="card p-5">
@@ -184,11 +184,11 @@
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600"> <a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project Change Project
</a> </a>
<form method="get" action="{{b "/dashboard"}}" class="flex flex-wrap items-center gap-2" id="dashboard-filter-form"> <form method="get" action="{{b "/dashboard"}}" class="flex flex-wrap items-center gap-2" id="dashboard-filter-form">
<input type="hidden" name="mode" value="daily"/> <input type="hidden" name="mode" value="daily"/>
<label class="text-xs font-medium text-slate-500">Tanggal Check-in</label> <label class="text-xs font-medium text-slate-500">Check-in Date</label>
<select name="date" <select name="date"
id="dashboard-date" id="dashboard-date"
onchange="this.form.submit()" onchange="this.form.submit()"
@@ -204,7 +204,7 @@
</select> </select>
<button type="submit" <button type="submit"
class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600"> class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
Lihat View
</button> </button>
</form> </form>
</div> </div>
@@ -277,7 +277,7 @@
<span class="sse-running-bar" aria-hidden="true"></span> <span class="sse-running-bar" aria-hidden="true"></span>
<span class="sse-updating-label" aria-hidden="true">Updating...</span> <span class="sse-updating-label" aria-hidden="true">Updating...</span>
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3"> <div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<h2 class="text-sm font-semibold text-slate-700">Station Status</h2> <h2 class="text-sm font-semibold text-slate-700">Station Status (Check In)</h2>
<span class="flex items-center gap-1.5 text-xs font-medium text-slate-400 animate-pulse"> <span class="flex items-center gap-1.5 text-xs font-medium text-slate-400 animate-pulse">
<span class="h-1.5 w-1.5 rounded-full bg-slate-300"></span> Connecting... <span class="h-1.5 w-1.5 rounded-full bg-slate-300"></span> Connecting...
</span> </span>
@@ -350,7 +350,7 @@ function fmtDate(s) {
return d + '/' + m + '/' + y; return d + '/' + m + '/' + y;
} }
function openPatientsModal() { function openPatientsModal(kpiFilter) {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const modal = document.getElementById('patients-modal'); const modal = document.getElementById('patients-modal');
const body = document.getElementById('patients-modal-body'); const body = document.getElementById('patients-modal-body');
@@ -359,6 +359,11 @@ function openPatientsModal() {
const mode = params.get('mode') || 'daily'; const mode = params.get('mode') || 'daily';
const date = params.get('date') || ''; const date = params.get('date') || '';
const dateEnd = params.get('date_end') || ''; const dateEnd = params.get('date_end') || '';
if (kpiFilter) {
params.set('kpi_filter', kpiFilter);
} else {
params.delete('kpi_filter');
}
if (mode === 'daily') { if (mode === 'daily') {
subtitle.textContent = date ? 'Tanggal: ' + fmtDate(date) : 'Hari ini'; subtitle.textContent = date ? 'Tanggal: ' + fmtDate(date) : 'Hari ini';
@@ -375,6 +380,31 @@ function openPatientsModal() {
.catch(() => { body.innerHTML = '<p class="p-6 text-sm text-red-500">Gagal memuat data.</p>'; }); .catch(() => { body.innerHTML = '<p class="p-6 text-sm text-red-500">Gagal memuat data.</p>'; });
} }
window.clearPatientsKpiFilter = function () {
const form = document.getElementById('patients-filter-form');
const body = document.getElementById('patients-modal-body');
if (!form || !body) return;
const params = new URLSearchParams(window.location.search);
const searchInput = form.querySelector('input[name="search"]');
const progressSelect = form.querySelector('select[name="progress_status"]');
const kpiInput = form.querySelector('input[name="kpi_filter"]');
const search = searchInput ? searchInput.value.trim() : '';
const progressStatus = progressSelect ? progressSelect.value.trim() : '';
if (search) params.set('search', search); else params.delete('search');
if (progressStatus) params.set('progress_status', progressStatus); else params.delete('progress_status');
params.delete('kpi_filter');
params.set('page', '1');
if (kpiInput) kpiInput.value = '';
body.innerHTML = '<div class="flex items-center justify-center py-16 text-slate-400"><svg class="h-5 w-5 animate-spin mr-2 text-brand-400" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>Memuat data...</div>';
fetch('{{b "/dashboard/patients"}}?' + params.toString())
.then(r => r.text())
.then(html => { body.innerHTML = html; })
.catch(() => { body.innerHTML = '<p class="p-6 text-sm text-red-500">Gagal memuat data.</p>'; });
};
(function() { (function() {
const sseTargets = new Set(['sse-kpi', 'sse-stations', 'sse-arrivals']); const sseTargets = new Set(['sse-kpi', 'sse-stations', 'sse-arrivals']);
const sseEventTargetMap = { const sseEventTargetMap = {

View File

@@ -7,9 +7,9 @@
<span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live <span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live
</span> </span>
{{end}} {{end}}
<button onclick="openPatientsModal()" <button onclick="openPatientsModal('invited')"
class="text-xs font-medium text-brand-500 hover:text-brand-700 transition-colors"> class="text-xs font-medium text-brand-500 hover:text-brand-700 transition-colors">
Lihat selengkapnya View Details
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
{{define "kpi"}} {{define "kpi"}}
<!-- Invited Staff --> <!-- Invited Staff -->
<article class="card border-l-4 border-l-brand-200 p-4"> <article onclick="openPatientsModal('invited')" class="card border-l-4 border-l-brand-200 p-4 cursor-pointer transition hover:bg-slate-50">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Invited Staff</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Invited Staff</p>
<svg class="h-4 w-4 text-brand-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4 text-brand-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -12,7 +12,7 @@
</article> </article>
<!-- Total Staff --> <!-- Total Staff -->
<article class="card border-l-4 border-l-brand-300 p-4"> <article onclick="openPatientsModal('total_staff')" class="card border-l-4 border-l-brand-300 p-4 cursor-pointer transition hover:bg-slate-50">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Staff</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Staff</p>
<svg class="h-4 w-4 text-brand-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4 text-brand-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -28,7 +28,7 @@
</article> </article>
<!-- Checked In --> <!-- Checked In -->
<article class="kpi-card kpi-card-inprogress card border-l-4 border-l-brand-500 p-4" data-kpi-kind="inprogress" data-kpi-value="{{.CheckedIn}}"> <article onclick="openPatientsModal('inprogress')" class="kpi-card kpi-card-inprogress card border-l-4 border-l-brand-500 p-4 cursor-pointer transition hover:bg-slate-50" data-kpi-kind="inprogress" data-kpi-value="{{.CheckedIn}}">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">In Progress</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">In Progress</p>
<svg class="h-4 w-4 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -40,7 +40,7 @@
</article> </article>
<!-- Checked Out --> <!-- Checked Out -->
<article class="kpi-card kpi-card-checkedout card border-l-4 border-l-emerald-500 p-4" data-kpi-kind="checkedout" data-kpi-value="{{.CheckedOut}}"> <article onclick="openPatientsModal('checkedout')" class="kpi-card kpi-card-checkedout card border-l-4 border-l-emerald-500 p-4 cursor-pointer transition hover:bg-slate-50" data-kpi-kind="checkedout" data-kpi-value="{{.CheckedOut}}">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked Out</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked Out</p>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">

View File

@@ -1,20 +1,149 @@
{{define "patients"}} {{define "patients"}}
<div class="border-b border-slate-100 px-5 py-3">
<form id="patients-filter-form" class="grid gap-2 md:grid-cols-[1fr_220px_auto]">
<input type="hidden" name="kpi_filter" value="{{.KpiFilter}}"/>
<input type="text" name="search" value="{{.Search}}" placeholder="Search name or preregister ID"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
<select name="progress_status"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .ProgressStatus ""}}selected{{end}}>All Progress</option>
<option value="not_yet" {{if eq .ProgressStatus "not_yet"}}selected{{end}}>Not Yet</option>
<option value="partial" {{if eq .ProgressStatus "partial"}}selected{{end}}>Progress / Partial</option>
<option value="done" {{if eq .ProgressStatus "done"}}selected{{end}}>Done</option>
</select>
<button type="submit"
class="rounded-lg bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</form>
{{if .KpiFilter}}
<div class="mt-2 inline-flex items-center gap-2 rounded-md border border-brand-200 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-700">
<span>
KPI Filter:
{{if eq .KpiFilter "invited"}}Invited Staff{{end}}
{{if eq .KpiFilter "total_staff"}}Total Staff{{end}}
{{if eq .KpiFilter "inprogress"}}In Progress{{end}}
{{if eq .KpiFilter "checkedout"}}Checked Out{{end}}
</span>
<button type="button" id="clear-kpi-filter-btn" onclick="if (window.clearPatientsKpiFilter) window.clearPatientsKpiFilter()" class="inline-flex h-4 w-4 items-center justify-center rounded-sm text-brand-700 hover:bg-brand-100" title="Clear KPI filter">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{end}}
</div>
<div id="patients-list-wrap">
<div id="patients-list" class="divide-y divide-slate-100">
{{template "patients-rows-content" .}}
</div>
<div id="patients-load-more" class="px-5 py-3 text-center text-xs text-slate-400">
{{if .HasMore}}Memuat data berikutnya...{{else}}Semua data sudah ditampilkan{{end}}
</div>
</div>
<script>
(function () {
const form = document.getElementById('patients-filter-form');
const list = document.getElementById('patients-list');
const loadMore = document.getElementById('patients-load-more');
if (!form || !list || !loadMore) return;
let loading = false;
let hasMore = {{if .HasMore}}true{{else}}false{{end}};
let nextPage = {{.NextPage}};
function buildParams(page) {
const params = new URLSearchParams(window.location.search);
const formData = new FormData(form);
const search = String(formData.get('search') || '').trim();
const progressStatus = String(formData.get('progress_status') || '').trim();
const kpiFilter = String(formData.get('kpi_filter') || '').trim();
if (search) params.set('search', search); else params.delete('search');
if (progressStatus) params.set('progress_status', progressStatus); else params.delete('progress_status');
if (kpiFilter) params.set('kpi_filter', kpiFilter); else params.delete('kpi_filter');
params.set('page', String(page));
params.set('chunk', '1');
return params;
}
function fetchPage(page, append) {
if (loading) return;
loading = true;
loadMore.textContent = 'Memuat data berikutnya...';
fetch('{{b "/dashboard/patients"}}?' + buildParams(page).toString())
.then(function (r) { return r.text(); })
.then(function (html) {
const tmp = document.createElement('div');
tmp.innerHTML = html;
const rows = tmp.querySelector('[data-patients-rows]');
const meta = tmp.querySelector('[data-patients-meta]');
if (!rows || !meta) {
throw new Error('invalid chunk');
}
if (append) {
list.insertAdjacentHTML('beforeend', rows.innerHTML);
} else {
list.innerHTML = rows.innerHTML;
}
hasMore = meta.getAttribute('data-has-more') === '1';
nextPage = parseInt(meta.getAttribute('data-next-page') || '1', 10);
loadMore.textContent = hasMore ? 'Scroll ke bawah untuk muat lagi...' : 'Semua data sudah ditampilkan';
})
.catch(function () {
loadMore.textContent = 'Gagal memuat data.';
})
.finally(function () {
loading = false;
});
}
form.addEventListener('submit', function (e) {
e.preventDefault();
hasMore = true;
nextPage = 1;
fetchPage(1, false);
});
const clearKpiBtn = document.getElementById('clear-kpi-filter-btn');
if (clearKpiBtn) {
clearKpiBtn.addEventListener('click', function () {
const kpiInput = form.querySelector('input[name="kpi_filter"]');
if (kpiInput) kpiInput.value = '';
hasMore = true;
nextPage = 1;
fetchPage(1, false);
});
}
const observer = new IntersectionObserver(function (entries) {
if (!entries[0].isIntersecting || loading || !hasMore) return;
fetchPage(nextPage, true);
}, { root: document.getElementById('patients-modal-body'), threshold: 0.1 });
observer.observe(loadMore);
})();
</script>
{{end}}
{{define "patients-rows"}}
<div data-patients-rows>
{{template "patients-rows-content" .}}
</div>
<div data-patients-meta data-has-more="{{if .HasMore}}1{{else}}0{{end}}" data-next-page="{{.NextPage}}"></div>
{{end}}
{{define "patients-rows-content"}}
{{if not .Patients}} {{if not .Patients}}
<div class="flex flex-col items-center justify-center py-16 text-slate-400"> <div class="flex flex-col items-center justify-center py-16 text-slate-400">
<svg class="mb-3 h-10 w-10 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <p class="text-sm font-medium">Belum ada data pasien pada filter ini.</p>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<p class="text-sm font-medium">Belum ada data pasien pada tanggal ini.</p>
</div> </div>
{{else}} {{else}}
<div class="divide-y divide-slate-100">
{{range .Patients}} {{range .Patients}}
{{if and (gt .DoneCount 0) (eq .DoneCount (len .Stations))}} {{if and (gt .DoneCount 0) (eq .DoneCount (len .Stations))}}
<div class="px-5 py-4 bg-emerald-50/60 transition-colors hover:bg-emerald-50"> <div class="px-5 py-4 bg-emerald-50/60 transition-colors hover:bg-emerald-50">
{{else}} {{else}}
<div class="px-5 py-4 hover:bg-slate-50 transition-colors"> <div class="px-5 py-4 hover:bg-slate-50 transition-colors">
{{end}} {{end}}
<!-- Row atas: nama + waktu -->
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600"> <div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600">
@@ -26,55 +155,60 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-3 text-xs"> <div class="flex items-center gap-3 text-xs">
<span class="num text-slate-500"> <span class="num text-slate-500">Check-in: <span class="font-semibold text-slate-700">{{if .InTime}}{{.InTime}}{{else}}-{{end}}</span></span>
Masuk: <span class="font-semibold text-slate-700">{{.InTime}}</span> {{if and .InTime .HasOut}}
</span> <span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700 num">Check-out: {{.OutTime}}</span>
{{if .HasOut}} {{else if .InTime}}
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700 num"> <span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 font-semibold text-amber-600">In Progress</span>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Keluar: {{.OutTime}}
</span>
{{else}}
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 font-semibold text-amber-600">
<span class="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse"></span>
Dalam proses
</span>
{{end}} {{end}}
<span class="text-slate-400"> {{if eq .ProgressStatus "done"}}
<span class="font-semibold text-brand-600">{{.DoneCount}}</span>/{{len .Stations}} station selesai <span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-1 font-semibold text-emerald-700">Done</span>
</span> {{else if eq .ProgressStatus "partial"}}
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-1 font-semibold text-amber-700">Progress / Partial</span>
{{else}}
<span class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-600">Not Yet</span>
{{end}}
<span class="text-slate-400"><span class="font-semibold text-brand-600">{{.DoneCount}}</span>/{{len .Stations}} station selesai</span>
</div> </div>
</div> </div>
<!-- Row bawah: station badges -->
{{if .Stations}} {{if .Stations}}
<div class="mt-2.5 flex flex-wrap gap-1.5 pl-10"> <div class="mt-2.5 flex flex-wrap gap-2 pl-10">
{{range .Stations}} {{range .Stations}}
{{if .Done}} {{if .Done}}
<span class="inline-flex max-w-full flex-col gap-0.5 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800 cursor-default"> <div class="inline-flex max-w-full flex-col border border-emerald-300 bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-800">
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg> <svg class="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
{{.Station}} {{.Station}}
</span> </span>
<span class="num pl-4 text-[10px] font-semibold text-emerald-700/80"> <span class="num text-[10px] font-semibold text-emerald-700/80">
Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}} Process: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Done: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
</span> </span>
</div>
{{else if .Progress}}
<div class="inline-flex max-w-full flex-col border border-amber-300 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800">
<span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5"/></svg>
{{.Station}}
</span> </span>
<span class="num text-[10px] font-semibold text-amber-700/80">
Process: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Done: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
</span>
</div>
{{else}} {{else}}
<span class="inline-flex max-w-full flex-col gap-0.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500 cursor-default"> <div class="inline-flex max-w-full flex-col border border-slate-300 bg-slate-50 px-2.5 py-1 text-xs font-medium text-slate-600">
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 flex-shrink-0 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="h-3 w-3 flex-shrink-0 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{.Station}} {{.Station}}
</span> </span>
<span class="num pl-4 text-[10px] font-semibold text-slate-400"> <span class="num text-[10px] font-semibold text-slate-400">
Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}} Process: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Done: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
</span>
</span> </span>
</div>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
</div>
{{end}} {{end}}
{{end}} {{end}}

View File

@@ -1,6 +1,6 @@
{{define "stations"}} {{define "stations"}}
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3"> <div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<h2 class="text-sm font-semibold text-slate-700">Station Status</h2> <h2 class="text-sm font-semibold text-slate-700">Station Status (Check In)</h2>
{{if .IsLive}} {{if .IsLive}}
<span class="flex items-center gap-1.5 text-xs font-medium text-emerald-600"> <span class="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
<span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live <span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live
@@ -13,8 +13,8 @@
<thead> <thead>
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-400"> <tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-400">
<th class="px-3 py-2">Station</th> <th class="px-3 py-2">Station</th>
<th class="hidden px-3 py-2 text-right sm:table-cell">Sudah</th> <th class="hidden px-3 py-2 text-right sm:table-cell">Done</th>
<th class="px-3 py-2 text-right">Belum</th> <th class="px-3 py-2 text-right">Pending</th>
<th class="px-3 py-2 text-right">Total</th> <th class="px-3 py-2 text-right">Total</th>
<th class="px-3 py-2 sm:w-40">Progress</th> <th class="px-3 py-2 sm:w-40">Progress</th>
</tr> </tr>

View File

@@ -17,7 +17,7 @@
</p> </p>
</div> </div>
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600"> <a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project Change Project
</a> </a>
</div> </div>
</section> </section>
@@ -71,13 +71,23 @@
</section> </section>
<section class="card p-4"> <section class="card p-4">
<form id="progress-filter-form" method="get" action="{{b "/progress"}}" class="grid gap-3 md:grid-cols-3"> <form id="progress-filter-form" method="get" action="{{b "/progress"}}" class="flex flex-wrap items-end gap-3">
<div class="md:col-span-2"> <div class="min-w-[240px] flex-[2_1_420px]">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label> <label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID" <input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/> class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div> </div>
<div> <div class="min-w-[180px] flex-1">
<label for="packet" class="mb-2 block text-sm font-medium text-slate-600">Packet</label>
<select id="packet" name="packet"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Packet ""}}selected{{end}}>All Packets</option>
{{range .PacketOptions}}
<option value="{{.Code}}" {{if eq .Code $.Packet}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="min-w-[180px] flex-1">
<label for="status" class="mb-2 block text-sm font-medium text-slate-600">Status</label> <label for="status" class="mb-2 block text-sm font-medium text-slate-600">Status</label>
<select id="status" name="status" <select id="status" name="status"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"> class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
@@ -88,7 +98,7 @@
<option value="not_published" {{if eq .Status "not_published"}}selected{{end}}>Not Published</option> <option value="not_published" {{if eq .Status "not_published"}}selected{{end}}>Not Published</option>
</select> </select>
</div> </div>
<div class="md:col-span-3 flex justify-end"> <div class="shrink-0">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600"> <button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter Filter
</button> </button>
@@ -122,8 +132,8 @@
<th class="px-4 py-3 font-medium">Name</th> <th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">NIP</th> <th class="px-4 py-3 font-medium">NIP</th>
<th class="px-4 py-3 font-medium">Posisi</th> <th class="px-4 py-3 font-medium">Posisi</th>
<th class="px-4 py-3 font-medium">Tests</th>
<th class="px-4 py-3 font-medium">Resume Status</th> <th class="px-4 py-3 font-medium">Resume Status</th>
<th class="px-4 py-3 font-medium">Validated</th>
<th class="px-4 py-3 font-medium">Published</th> <th class="px-4 py-3 font-medium">Published</th>
</tr> </tr>
</thead> </thead>
@@ -133,6 +143,9 @@
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td> <td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 num text-slate-500">{{.NIP}}</td> <td class="px-4 py-3 num text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td> <td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-[200px]">
{{if .Tests}}<span title="{{.Tests}}" class="line-clamp-2">{{.Tests}}</span>{{else}}-{{end}}
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{if .ResumeStatus}} {{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span> <span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
@@ -140,13 +153,6 @@
<span class="text-xs text-slate-400">—</span> <span class="text-xs text-slate-400">—</span>
{{end}} {{end}}
</td> </td>
<td class="px-4 py-3">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Y</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
{{end}}
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{if eq .Published "Y"}} {{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Y</span> <span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Y</span>
@@ -167,17 +173,13 @@
<div> <div>
<p class="font-semibold text-slate-700">{{.Name}}</p> <p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p> <p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
{{if .Tests}}<p class="mt-1 text-xs text-slate-500 line-clamp-1">{{.Tests}}</p>{{end}}
</div> </div>
{{if .ResumeStatus}} {{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span> <span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
{{end}} {{end}}
</div> </div>
<div class="mt-2 flex gap-2"> <div class="mt-2 flex gap-2">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Validated</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Validated</span>
{{end}}
{{if eq .Published "Y"}} {{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Published</span> <span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Published</span>
{{else}} {{else}}
@@ -190,7 +192,7 @@
<div id="progress-load-more" class="px-4 pb-4"> <div id="progress-load-more" class="px-4 pb-4">
{{if .HasMore}} {{if .HasMore}}
<div <div
hx-get='{{b "/progress/list"}}?search={{.Search}}&status={{.Status}}&page={{.NextPage}}' hx-get='{{b "/progress/list"}}?search={{urlquery .Search}}&status={{urlquery .Status}}&packet={{urlquery .Packet}}&page={{.NextPage}}'
hx-trigger="revealed" hx-trigger="revealed"
hx-swap="outerHTML" hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500"> class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
@@ -236,6 +238,9 @@
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td> <td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 num text-slate-500">{{.NIP}}</td> <td class="px-4 py-3 num text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td> <td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-[200px]">
{{if .Tests}}<span title="{{.Tests}}" class="line-clamp-2">{{.Tests}}</span>{{else}}-{{end}}
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{if .ResumeStatus}} {{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span> <span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
@@ -243,13 +248,6 @@
<span class="text-xs text-slate-400">—</span> <span class="text-xs text-slate-400">—</span>
{{end}} {{end}}
</td> </td>
<td class="px-4 py-3">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Y</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
{{end}}
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{if eq .Published "Y"}} {{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Y</span> <span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Y</span>
@@ -261,24 +259,20 @@
{{end}} {{end}}
</tbody> </tbody>
<div id="progress-mobile-rows" hx-swap-oob="beforeend"> <div id="progress-mobile-rows" hx-swap-oob="beforeend" class="md:hidden" style="display:contents">
{{range .FilteredRows}} {{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3"> <article class="rounded-xl border border-slate-200 p-3 md:hidden">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<p class="font-semibold text-slate-700">{{.Name}}</p> <p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p> <p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
{{if .Tests}}<p class="mt-1 text-xs text-slate-500 line-clamp-1">{{.Tests}}</p>{{end}}
</div> </div>
{{if .ResumeStatus}} {{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span> <span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
{{end}} {{end}}
</div> </div>
<div class="mt-2 flex gap-2"> <div class="mt-2 flex gap-2">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Validated</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Validated</span>
{{end}}
{{if eq .Published "Y"}} {{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Published</span> <span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Published</span>
{{else}} {{else}}
@@ -292,7 +286,7 @@
<div id="progress-load-more" hx-swap-oob="outerHTML"> <div id="progress-load-more" hx-swap-oob="outerHTML">
{{if .HasMore}} {{if .HasMore}}
<div <div
hx-get='{{b "/progress/list"}}?search={{.Search}}&status={{.Status}}&page={{.NextPage}}' hx-get='{{b "/progress/list"}}?search={{urlquery .Search}}&status={{urlquery .Status}}&packet={{urlquery .Packet}}&page={{.NextPage}}'
hx-trigger="revealed" hx-trigger="revealed"
hx-swap="outerHTML" hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500"> class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">

View File

@@ -18,7 +18,7 @@
</p> </p>
</div> </div>
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600"> <a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project Change Project
</a> </a>
</div> </div>
</section> </section>
@@ -39,13 +39,23 @@
{{/* Section 3: Filter form */}} {{/* Section 3: Filter form */}}
<section class="card p-4"> <section class="card p-4">
<form method="get" action="{{b "/result"}}" class="grid gap-3 md:grid-cols-3"> <form method="get" action="{{b "/result"}}" class="flex flex-wrap items-end gap-3">
<div class="md:col-span-2"> <div class="min-w-[240px] flex-[2_1_420px]">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label> <label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID" <input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/> class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div> </div>
<div> <div class="min-w-[180px] flex-1">
<label for="packet" class="mb-2 block text-sm font-medium text-slate-600">Packet</label>
<select id="packet" name="packet"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Packet ""}}selected{{end}}>All Packets</option>
{{range .PacketOptions}}
<option value="{{.Code}}" {{if eq .Code $.Packet}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="min-w-[180px] flex-1">
<label for="filter" class="mb-2 block text-sm font-medium text-slate-600">Status PDF</label> <label for="filter" class="mb-2 block text-sm font-medium text-slate-600">Status PDF</label>
<select id="filter" name="filter" <select id="filter" name="filter"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"> class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
@@ -54,7 +64,7 @@
<option value="no_pdf" {{if eq .Filter "no_pdf"}}selected{{end}}>No PDF</option> <option value="no_pdf" {{if eq .Filter "no_pdf"}}selected{{end}}>No PDF</option>
</select> </select>
</div> </div>
<div class="md:col-span-3 flex justify-end"> <div class="shrink-0">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600"> <button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter Filter
</button> </button>
@@ -80,6 +90,7 @@
<th class="px-4 py-3 font-medium">Employee ID</th> <th class="px-4 py-3 font-medium">Employee ID</th>
<th class="px-4 py-3 font-medium">Patient</th> <th class="px-4 py-3 font-medium">Patient</th>
<th class="px-4 py-3 font-medium">Department</th> <th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Tests</th>
<th class="px-4 py-3 font-medium">Report Date</th> <th class="px-4 py-3 font-medium">Report Date</th>
<th class="px-4 py-3 font-medium">Action</th> <th class="px-4 py-3 font-medium">Action</th>
</tr> </tr>
@@ -90,12 +101,15 @@
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td> <td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td> <td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td> <td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-[200px]">
{{if .Tests}}<span title="{{.Tests}}" class="line-clamp-2">{{.Tests}}</span>{{else}}-{{end}}
</td>
<td class="num px-4 py-3 text-slate-500"> <td class="num px-4 py-3 text-slate-500">
{{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300">—</span>{{end}} {{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300">—</span>{{end}}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{if .FileUrl}} {{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')" <button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}', '{{.ReportVersion}}')"
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600"> class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF View PDF
</button> </button>
@@ -116,10 +130,11 @@
<div> <div>
<p class="font-semibold text-slate-700">{{.Name}}</p> <p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p> <p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
{{if .Tests}}<p class="mt-1 text-xs text-slate-500 line-clamp-1">{{.Tests}}</p>{{end}}
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}} {{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
</div> </div>
{{if .FileUrl}} {{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')" <button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}', '{{.ReportVersion}}')"
class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600"> class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF View PDF
</button> </button>
@@ -130,17 +145,39 @@
</article> </article>
{{end}} {{end}}
</div> </div>
<div id="result-load-more" class="px-4 pb-4"> <div class="hidden items-center justify-between gap-2 border-t border-slate-100 px-4 py-3 md:flex">
<div class="text-xs text-slate-400">Page {{.Page}} of {{.TotalPages}}</div>
<div class="flex items-center gap-2">
{{if .HasPrev}}
<a href='{{b "/result"}}?search={{urlquery .Search}}&filter={{urlquery .Filter}}&packet={{urlquery .Packet}}&page={{.PrevPage}}'
class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-brand-300 hover:text-brand-600">
Previous
</a>
{{else}}
<span class="rounded-lg border border-slate-100 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-300">Previous</span>
{{end}}
{{if .HasMore}}
<a href='{{b "/result"}}?search={{urlquery .Search}}&filter={{urlquery .Filter}}&packet={{urlquery .Packet}}&page={{.NextPage}}'
class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-brand-300 hover:text-brand-600">
Next
</a>
{{else}}
<span class="rounded-lg border border-slate-100 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-300">Next</span>
{{end}}
</div>
</div>
<div id="result-load-more" class="px-4 pb-4 md:hidden">
{{if .HasMore}} {{if .HasMore}}
<div <div
hx-get='{{b "/result/list"}}?search={{.Search}}&filter={{.Filter}}&page={{.NextPage}}' hx-get='{{b "/result/list"}}?search={{urlquery .Search}}&filter={{urlquery .Filter}}&packet={{urlquery .Packet}}&page={{.NextPage}}'
hx-trigger="revealed" hx-trigger="revealed, click"
hx-swap="outerHTML" hx-swap="none"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500"> class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
Memuat data berikutnya... Load next data...
</div> </div>
{{else}} {{else}}
<div class="text-center text-xs text-slate-400">Semua data sudah ditampilkan</div> <div class="text-center text-xs text-slate-400">All data is displayed</div>
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
@@ -187,11 +224,24 @@ function normalizePdfUrl(rawUrl) {
} }
} }
function openPDFModal(url, name) { function buildPdfUrl(rawUrl, version) {
const safeUrl = normalizePdfUrl(url); const safeUrl = normalizePdfUrl(rawUrl);
document.getElementById('pdf-modal-frame').src = safeUrl; if (!version) return safeUrl;
try {
const u = new URL(safeUrl, window.location.origin);
u.searchParams.set('v', version);
return u.toString();
} catch (_) {
const joiner = safeUrl.includes('?') ? '&' : '?';
return safeUrl + joiner + 'v=' + encodeURIComponent(version);
}
}
function openPDFModal(url, name, version) {
const finalUrl = buildPdfUrl(url, version);
document.getElementById('pdf-modal-frame').src = finalUrl;
document.getElementById('pdf-modal-title').textContent = name; document.getElementById('pdf-modal-title').textContent = name;
document.getElementById('pdf-modal-link').href = safeUrl; document.getElementById('pdf-modal-link').href = finalUrl;
const modal = document.getElementById('pdf-modal'); const modal = document.getElementById('pdf-modal');
modal.classList.remove('hidden'); modal.classList.remove('hidden');
modal.classList.add('flex'); modal.classList.add('flex');
@@ -217,12 +267,15 @@ document.addEventListener('keydown', function(e) {
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td> <td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td> <td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td> <td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-[200px]">
{{if .Tests}}<span title="{{.Tests}}" class="line-clamp-2">{{.Tests}}</span>{{else}}-{{end}}
</td>
<td class="num px-4 py-3 text-slate-500"> <td class="num px-4 py-3 text-slate-500">
{{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300">—</span>{{end}} {{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300">—</span>{{end}}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{if .FileUrl}} {{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')" <button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}', '{{.ReportVersion}}')"
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600"> class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF View PDF
</button> </button>
@@ -234,17 +287,18 @@ document.addEventListener('keydown', function(e) {
{{end}} {{end}}
</tbody> </tbody>
<div id="result-mobile-rows" hx-swap-oob="beforeend"> <div id="result-mobile-rows" hx-swap-oob="beforeend" class="md:hidden" style="display:contents">
{{range .FilteredRows}} {{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3"> <article class="rounded-xl border border-slate-200 p-3 md:hidden">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<p class="font-semibold text-slate-700">{{.Name}}</p> <p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p> <p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
{{if .Tests}}<p class="mt-1 text-xs text-slate-500 line-clamp-1">{{.Tests}}</p>{{end}}
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}} {{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
</div> </div>
{{if .FileUrl}} {{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')" <button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}', '{{.ReportVersion}}')"
class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600"> class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF View PDF
</button> </button>
@@ -256,17 +310,17 @@ document.addEventListener('keydown', function(e) {
{{end}} {{end}}
</div> </div>
<div id="result-load-more" hx-swap-oob="outerHTML"> <div id="result-load-more" hx-swap-oob="outerHTML" class="md:hidden">
{{if .HasMore}} {{if .HasMore}}
<div <div
hx-get='{{b "/result/list"}}?search={{.Search}}&filter={{.Filter}}&page={{.NextPage}}' hx-get='{{b "/result/list"}}?search={{urlquery .Search}}&filter={{urlquery .Filter}}&packet={{urlquery .Packet}}&page={{.NextPage}}'
hx-trigger="revealed" hx-trigger="revealed, click"
hx-swap="outerHTML" hx-swap="none"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500"> class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
Memuat data berikutnya... Load next data...
</div> </div>
{{else}} {{else}}
<div class="text-center text-xs text-slate-400">Semua data sudah ditampilkan</div> <div class="text-center text-xs text-slate-400">All data is displayed</div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}