Update dashboard filters and packet views

This commit is contained in:
sas.fajri
2026-05-07 12:04:09 +07:00
parent da419950ce
commit 11f0d0436e
17 changed files with 1008 additions and 342 deletions

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 {
filteredByKPI = append(filteredByKPI, p)
}
case "checkedout":
if p.HasOut {
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

@@ -391,6 +391,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 +405,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 +417,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 +444,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 +491,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 +510,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,6 +9,9 @@ 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
} }
@@ -29,6 +32,17 @@ 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
@@ -53,7 +67,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); err != nil {
continue continue
} }
result = append(result, r) result = append(result, r)
@@ -71,14 +85,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 +108,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 +118,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={{.Date}}&search={{.Search}}&paket={{.Paket}}&dept={{.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={{.Date}}&search={{.Search}}&paket={{.Paket}}&dept={{.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={{.Search}}&status={{.Status}}&packet={{.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={{.Search}}&status={{.Status}}&packet={{.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,6 +101,9 @@
<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>
@@ -116,6 +130,7 @@
<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}}
@@ -133,7 +148,7 @@
<div id="result-load-more" class="px-4 pb-4"> <div id="result-load-more" class="px-4 pb-4">
{{if .HasMore}} {{if .HasMore}}
<div <div
hx-get='{{b "/result/list"}}?search={{.Search}}&filter={{.Filter}}&page={{.NextPage}}' hx-get='{{b "/result/list"}}?search={{.Search}}&filter={{.Filter}}&packet={{.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">
@@ -217,6 +232,9 @@ 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>
@@ -234,13 +252,14 @@ 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}}
@@ -259,7 +278,7 @@ document.addEventListener('keydown', function(e) {
<div id="result-load-more" hx-swap-oob="outerHTML"> <div id="result-load-more" hx-swap-oob="outerHTML">
{{if .HasMore}} {{if .HasMore}}
<div <div
hx-get='{{b "/result/list"}}?search={{.Search}}&filter={{.Filter}}&page={{.NextPage}}' hx-get='{{b "/result/list"}}?search={{.Search}}&filter={{.Filter}}&packet={{.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">