Files
cpone_dashboard/cpone-dashboard/menu/dashboard/handler.go
sas.fajri 558f44b7c2 BUG-DASH-280826 - perbaiki sumber data checkout dan definisi Checked Out di dashboard
sp_generate_dashboard_data menebak Mcu_CheckinoutOutTime dari MAX() timestamp
event lab (sample receive/sampling done), bukan dari checkout fisik pasien,
sehingga pasien yang belum checkout bisa tampil "Checked Out" walau stationnya
belum selesai. Ganti sumbernya ke cpone.preregister_checkin_checkout (log
checkin/checkout asli) dan hentikan pemaksaan Mcu_CheckinoutID = order_id yang
bentrok untuk project multi-hari.

KPI "Checked Out" di GetKPI juga diselaraskan dengan detail pasien: sekarang
hitung dari GetAllPatients dan mensyaratkan semua station wajib sudah selesai,
bukan cuma checkout fisik saja, supaya angka KPI dan modal detail selalu sinkron.
2026-08-28 16:09:04 +07:00

368 lines
9.9 KiB
Go

package dashboard
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"embed"
"encoding/json"
"html/template"
"log"
"net/http"
"strconv"
"strings"
"time"
)
var templateFS *embed.FS
var funcMap template.FuncMap
type StationsPartial struct {
Rows []StationRow
IsLive bool
}
type ArrivalsPartial struct {
Rows []ArrivalRow
IsLive bool
}
type PatientsPartial struct {
Patients []PatientDetail
IsRange bool
Search string
ProgressStatus string
KpiFilter string
Page int
PageSize int
HasMore bool
NextPage int
}
type PageData struct {
Username string
McuID int
Project ProjectInfo
CurrentProject projects.ProjectItem
AvailableDates []string
Mode string
DateFrom string
DateTo string
IsRange bool
IsLive bool
KPI KPIData
TAT TATData
Stations []StationRow
Arrivals []ArrivalRow
TATChart template.JS
TrendChart template.JS
}
var basePath string
func SetTemplateFS(fs *embed.FS) { templateFS = fs }
func SetTemplateFuncs(fm template.FuncMap) { funcMap = fm }
func SetBasePath(p string) { basePath = p }
func parse(files ...string) *template.Template {
return template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, files...))
}
func defaultDailyDate(project ProjectInfo) string {
today := time.Now().Format("2006-01-02")
if project.StartDate == "" {
return today
}
if project.EndDate != "" && today > project.EndDate {
return project.StartDate
}
if today < project.StartDate {
return project.StartDate
}
return today
}
func containsDate(dates []string, target string) bool {
for _, d := range dates {
if d == target {
return true
}
}
return false
}
func activeDateRange(r *http.Request, project ProjectInfo, availableDates []string) (mode, from, to string) {
mode = "daily"
reqDate := r.URL.Query().Get("date")
switch {
case reqDate != "" && containsDate(availableDates, reqDate):
from = reqDate
case containsDate(availableDates, time.Now().Format("2006-01-02")):
from = time.Now().Format("2006-01-02")
case len(availableDates) > 0:
from = availableDates[0]
default:
from = defaultDailyDate(project)
}
to = from
return
}
func activeMcuID(r *http.Request) int {
if id, _ := strconv.Atoi(r.URL.Query().Get("mcu_id")); id > 0 {
return id
}
return auth.SelectedProjectID(r)
}
func toJS(v interface{}) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
}
func Index(w http.ResponseWriter, r *http.Request) {
mcuID := activeMcuID(r)
username := auth.Username(r)
if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
ok, err := projects.HasAccess(username, mcuID)
if err != nil {
log.Printf("[dashboard] HasAccess error: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
project, err := GetProject(mcuID)
if err != nil {
log.Printf("[dashboard] GetActiveProject error: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
currentProject := projects.ProjectItem{}
if item, ok, err := projects.GetUserProject(username, mcuID); err != nil {
log.Printf("[dashboard] GetUserProject error: %v", err)
} else if ok {
currentProject = item
auth.SetSelectedProject(w, mcuID)
}
availableDates, err := GetCheckinDates(project.McuID)
if err != nil {
log.Printf("[dashboard] GetCheckinDates error: %v", err)
}
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
isRange := dateFrom != dateTo
kpi, err := GetKPI(project.McuID, dateFrom, dateTo)
if err != nil {
log.Printf("[dashboard] GetKPI error: %v", err)
}
tat, err := GetTAT(project.McuID, dateFrom, dateTo)
if err != nil {
log.Printf("[dashboard] GetTAT error: %v", err)
}
stations, err := GetStations(project.McuID, dateFrom, dateTo)
if err != nil {
log.Printf("[dashboard] GetStations error: %v", err)
}
arrivals, err := GetArrivals(project.McuID, dateFrom, dateTo, 8)
if err != nil {
log.Printf("[dashboard] GetArrivals error: %v", err)
}
hourlyTAT, err := GetPeriodTAT(project.McuID, dateFrom, dateTo, isRange)
if err != nil {
log.Printf("[dashboard] GetPeriodTAT error: %v", err)
}
trend, err := GetPeriodTrend(project.McuID, dateFrom, dateTo, isRange)
if err != nil {
log.Printf("[dashboard] GetPeriodTrend error: %v", err)
}
// Build chart JSON payloads
tatLabels, tatValues := []string{}, []float64{}
for _, p := range hourlyTAT {
tatLabels = append(tatLabels, p.Label)
tatValues = append(tatValues, p.Value)
}
trendLabels, trendCI, trendCO := []string{}, []int{}, []int{}
for _, p := range trend {
trendLabels = append(trendLabels, p.Label)
trendCI = append(trendCI, p.CheckedIn)
trendCO = append(trendCO, p.CheckedOut)
}
today := time.Now().Format("2006-01-02")
data := PageData{
Username: username,
McuID: project.McuID,
Project: project,
CurrentProject: currentProject,
AvailableDates: availableDates,
Mode: mode,
DateFrom: dateFrom,
DateTo: dateTo,
IsRange: isRange,
IsLive: mode == "daily" && dateFrom == today,
KPI: kpi,
TAT: tat,
Stations: stations,
Arrivals: arrivals,
TATChart: toJS(map[string]interface{}{
"labels": tatLabels,
"values": tatValues,
}),
TrendChart: toJS(map[string]interface{}{
"labels": trendLabels,
"checkedIn": trendCI,
"checkedOut": trendCO,
}),
}
t := parse("templates/layout/base.html", "templates/dashboard/index.html")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
log.Printf("[dashboard] template error: %v", err)
}
}
func Patients(w http.ResponseWriter, r *http.Request) {
project, err := GetProject(activeMcuID(r))
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
availableDates, _ := GetCheckinDates(project.McuID)
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
patients, err := GetAllPatients(project.McuID, dateFrom, dateTo)
if err != nil {
log.Printf("[dashboard] GetAllPatients error: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
search := strings.TrimSpace(r.URL.Query().Get("search"))
progressStatus := strings.TrimSpace(r.URL.Query().Get("progress_status"))
kpiFilter := strings.TrimSpace(r.URL.Query().Get("kpi_filter"))
if kpiFilter != "" {
filteredByKPI := make([]PatientDetail, 0, len(patients))
for _, p := range patients {
switch kpiFilter {
case "invited":
filteredByKPI = append(filteredByKPI, p)
case "total_staff":
if p.InTime != "" {
filteredByKPI = append(filteredByKPI, p)
}
case "inprogress":
if p.InTime != "" && !(p.HasOut && p.ProgressStatus == "done") {
filteredByKPI = append(filteredByKPI, p)
}
case "checkedout":
if p.HasOut && p.ProgressStatus == "done" {
filteredByKPI = append(filteredByKPI, p)
}
default:
filteredByKPI = append(filteredByKPI, p)
}
}
patients = filteredByKPI
}
if search != "" || progressStatus != "" {
lowerSearch := strings.ToLower(search)
filtered := make([]PatientDetail, 0, len(patients))
for _, p := range patients {
if progressStatus != "" && p.ProgressStatus != progressStatus {
continue
}
if lowerSearch != "" && !strings.Contains(strings.ToLower(p.Name), lowerSearch) && !strings.Contains(strconv.Itoa(p.ID), lowerSearch) {
continue
}
filtered = append(filtered, p)
}
patients = filtered
}
page := 1
if p, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("page"))); err == nil && p > 0 {
page = p
}
pageSize := 30
start := (page - 1) * pageSize
if start > len(patients) {
start = len(patients)
}
end := start + pageSize
if end > len(patients) {
end = len(patients)
}
pagedPatients := patients[start:end]
hasMore := end < len(patients)
data := PatientsPartial{
Patients: pagedPatients,
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")
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)
}
}
func KPI(w http.ResponseWriter, r *http.Request) {
project, _ := GetProject(activeMcuID(r))
availableDates, _ := GetCheckinDates(project.McuID)
_, from, to := activeDateRange(r, project, availableDates)
data, err := GetKPI(project.McuID, from, to)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
t := parse("templates/dashboard/partials/kpi.html")
t.ExecuteTemplate(w, "kpi", data)
}
func Stations(w http.ResponseWriter, r *http.Request) {
project, _ := GetProject(activeMcuID(r))
availableDates, _ := GetCheckinDates(project.McuID)
_, from, to := activeDateRange(r, project, availableDates)
rows, err := GetStations(project.McuID, from, to)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
t := parse("templates/dashboard/partials/stations.html")
t.ExecuteTemplate(w, "stations", StationsPartial{Rows: rows, IsLive: from == time.Now().Format("2006-01-02")})
}
func Arrivals(w http.ResponseWriter, r *http.Request) {
project, _ := GetProject(activeMcuID(r))
availableDates, _ := GetCheckinDates(project.McuID)
_, from, to := activeDateRange(r, project, availableDates)
rows, err := GetArrivals(project.McuID, from, to, 8)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
t := parse("templates/dashboard/partials/arrivals.html")
t.ExecuteTemplate(w, "arrivals", rows)
}