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.
368 lines
9.9 KiB
Go
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)
|
|
}
|