Initial commit

This commit is contained in:
sas.fajri
2026-04-30 14:27:01 +07:00
commit e29e943c27
70 changed files with 8909 additions and 0 deletions

View File

@@ -0,0 +1,287 @@
package dashboard
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"embed"
"encoding/json"
"html/template"
"log"
"net/http"
"strconv"
"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
}
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
}
data := PatientsPartial{
Patients: patients,
IsRange: mode != "daily" || dateFrom != dateTo,
}
t := parse("templates/dashboard/partials/patients.html")
if err := t.ExecuteTemplate(w, "patients", 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)
}

View File

@@ -0,0 +1,505 @@
package dashboard
import (
"cpone-dashboard/db"
"database/sql"
"fmt"
)
const checkinOutTimestampExpr = "TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutOutTime)"
type ProjectInfo struct {
McuID int
CorporateName string
Label string
Number string
StartDate string
EndDate string
TotalStaff int
}
type KPIData struct {
InvitedStaff int // dari mcu_participant_daily, date-filtered — untuk widget kanan atas
TotalStaff int // dari mcu_checkinout, seluruh project — untuk KPI card
CheckedIn int // dari mcu_checkinout, date-filtered
CheckedOut int // dari mcu_checkinout, date-filtered
}
type StationRow struct {
Station string
Processed int
Pending int
Total int
Pct float64
}
type ArrivalRow struct {
Name string
InTime string
Date string
Station string
}
type TATData struct {
AvgMinutes int
Fastest int
Median int
CheckedOut int
}
type ChartPoint struct {
Label string
Value float64
}
type TrendPoint struct {
Label string
CheckedIn int
CheckedOut int
}
// GetProject returns project by mcuID. If mcuID == 0, returns the active project.
func GetProject(mcuID int) (ProjectInfo, error) {
var p ProjectInfo
var err error
const cols = `SELECT Mcu_ProjectMcuID, Mcu_ProjectCorporateName, Mcu_ProjectLabel,
Mcu_ProjectNumber,
DATE_FORMAT(Mcu_ProjectStartDate, '%Y-%m-%d'),
DATE_FORMAT(Mcu_ProjectEndDate, '%Y-%m-%d'),
Mcu_ProjectTotalParticipant
FROM mcu_project`
if mcuID > 0 {
err = db.DB.QueryRow(cols+` WHERE Mcu_ProjectMcuID = ?`, mcuID).
Scan(&p.McuID, &p.CorporateName, &p.Label, &p.Number, &p.StartDate, &p.EndDate, &p.TotalStaff)
} else {
err = db.DB.QueryRow(cols+` WHERE Mcu_ProjectIsActive = 'Y' ORDER BY Mcu_ProjectStartDate DESC LIMIT 1`).
Scan(&p.McuID, &p.CorporateName, &p.Label, &p.Number, &p.StartDate, &p.EndDate, &p.TotalStaff)
}
if err == sql.ErrNoRows {
return p, nil
}
return p, err
}
func GetKPI(mcuID int, dateFrom, dateTo string) (KPIData, error) {
var d KPIData
// Invited staff: dari mcu_participant_daily, date-filtered — widget kanan atas
db.DB.QueryRow(`
SELECT COALESCE(SUM(Mcu_ParticipantDailyTotal), 0)
FROM mcu_participant_daily
WHERE Mcu_ParticipantDailyMcuID = ?
AND Mcu_ParticipantDailyDate BETWEEN ? AND ?
AND Mcu_ParticipantDailyIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.InvitedStaff)
// Total staff: semua yang datang (checkin) pada tanggal filter
db.DB.QueryRow(`
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.TotalStaff)
// Checked-in: masih di dalam (belum checkout)
db.DB.QueryRow(`
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NULL
AND Mcu_CheckinoutIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.CheckedIn)
// Checked-out: sudah selesai
db.DB.QueryRow(`
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.CheckedOut)
return d, nil
}
func GetCheckinDates(mcuID int) ([]string, error) {
rows, err := db.DB.Query(`
SELECT DATE_FORMAT(Mcu_CheckinoutDate, '%Y-%m-%d') AS checkin_date
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutIsActive = 'Y'
GROUP BY Mcu_CheckinoutDate
ORDER BY Mcu_CheckinoutDate DESC
`, mcuID)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var d string
if rows.Scan(&d) == nil && d != "" {
dates = append(dates, d)
}
}
return dates, nil
}
func GetTAT(mcuID int, dateFrom, dateTo string) (TATData, error) {
var d TATData
rows, err := db.DB.Query(`
SELECT TIMESTAMPDIFF(MINUTE,
TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutInTime),
`+checkinOutTimestampExpr+`
) AS tat
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutInTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
ORDER BY tat
`, mcuID, dateFrom, dateTo)
if err != nil {
return d, err
}
defer rows.Close()
var vals []int
for rows.Next() {
var v int
if rows.Scan(&v) == nil && v > 0 {
vals = append(vals, v)
}
}
if len(vals) == 0 {
return d, nil
}
sum := 0
for _, v := range vals {
sum += v
}
d.CheckedOut = len(vals)
d.AvgMinutes = sum / len(vals)
d.Fastest = vals[0]
d.Median = vals[len(vals)/2]
return d, nil
}
func GetStations(mcuID int, dateFrom, dateTo string) ([]StationRow, error) {
rows, err := db.DB.Query(`
SELECT rs.station_name,
COUNT(DISTINCT CASE
WHEN spd.first_done_date IS NULL OR spd.first_done_date >= mc.Mcu_CheckinoutDate
THEN mc.Mcu_CheckinoutPreregisterID
END) AS total_required,
COUNT(DISTINCT CASE
WHEN spd.first_done_date = mc.Mcu_CheckinoutDate
THEN mc.Mcu_CheckinoutPreregisterID
ELSE NULL
END) AS processed
FROM mcu_checkinout mc
JOIN mcu_patient_required_station rs
ON rs.preregister_id = mc.Mcu_CheckinoutPreregisterID
AND rs.mcu_id = mc.Mcu_CheckinoutMcuID
LEFT JOIN (
SELECT
sp.Mcu_StationProgressPreregisterID AS preregister_id,
sp.Mcu_StationProgressStationID AS station_id,
MIN(CASE
WHEN sp.Mcu_StationProgressSource = 'lab'
AND sp.Mcu_StationProgressReceiveAt IS NOT NULL
THEN DATE(sp.Mcu_StationProgressReceiveAt)
WHEN sp.Mcu_StationProgressSource = 'nonlab'
AND sp.Mcu_StationProgressDoneAt IS NOT NULL
THEN DATE(sp.Mcu_StationProgressDoneAt)
ELSE NULL
END) AS first_done_date
FROM mcu_station_progress sp
WHERE sp.Mcu_StationProgressMcuID = ?
GROUP BY sp.Mcu_StationProgressPreregisterID, sp.Mcu_StationProgressStationID
) spd
ON spd.preregister_id = mc.Mcu_CheckinoutPreregisterID
AND spd.station_id = rs.sample_station_id
WHERE mc.Mcu_CheckinoutMcuID = ?
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
AND mc.Mcu_CheckinoutIsActive = 'Y'
GROUP BY rs.station_name
HAVING total_required > 0
ORDER BY processed DESC, rs.station_name ASC
`, mcuID, mcuID, dateFrom, dateTo)
if err != nil {
return nil, err
}
defer rows.Close()
var result []StationRow
for rows.Next() {
var r StationRow
if err := rows.Scan(&r.Station, &r.Total, &r.Processed); err != nil {
continue
}
r.Pending = r.Total - r.Processed
if r.Pending < 0 {
r.Pending = 0
}
if r.Total > 0 {
r.Pct = float64(r.Processed) / float64(r.Total) * 100
}
result = append(result, r)
}
return result, nil
}
func GetArrivals(mcuID int, dateFrom, dateTo string, limit int) ([]ArrivalRow, error) {
rows, err := db.DB.Query(`
SELECT mp.Mcu_PatientName,
DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i:%s') AS in_time,
DATE_FORMAT(mc.Mcu_CheckinoutDate, '%Y-%m-%d') AS checkin_date,
COALESCE(
(SELECT Mcu_StationProgressStationName
FROM mcu_station_progress
WHERE Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
AND Mcu_StationProgressCheckinDate = mc.Mcu_CheckinoutDate
AND Mcu_StationProgressDoneAt IS NOT NULL
ORDER BY Mcu_StationProgressDoneAt DESC LIMIT 1),
'Check-in'
) AS last_station
FROM mcu_checkinout mc
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID
WHERE mc.Mcu_CheckinoutMcuID = ?
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
AND mc.Mcu_CheckinoutIsActive = 'Y'
ORDER BY mc.Mcu_CheckinoutDate DESC, mc.Mcu_CheckinoutInTime DESC
LIMIT ?
`, mcuID, dateFrom, dateTo, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ArrivalRow
for rows.Next() {
var r ArrivalRow
if rows.Scan(&r.Name, &r.InTime, &r.Date, &r.Station) == nil {
result = append(result, r)
}
}
return result, nil
}
// GetPeriodTAT — tampilkan agregasi per jam untuk daily maupun range
func GetPeriodTAT(mcuID int, dateFrom, dateTo string, isRange bool) ([]ChartPoint, error) {
_ = isRange
groupExpr := "HOUR(Mcu_CheckinoutInTime)"
labelExpr := "CONCAT(LPAD(HOUR(Mcu_CheckinoutInTime), 2, '0'), ':00')"
q := fmt.Sprintf(`
SELECT %s AS label,
AVG(TIMESTAMPDIFF(MINUTE,
TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutInTime),
%s
)) AS avg_tat
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutInTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
GROUP BY %s
ORDER BY %s
`, labelExpr, checkinOutTimestampExpr, groupExpr, groupExpr)
rows, err := db.DB.Query(q, mcuID, dateFrom, dateTo)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ChartPoint
for rows.Next() {
var p ChartPoint
if rows.Scan(&p.Label, &p.Value) == nil {
result = append(result, p)
}
}
return result, nil
}
// GetPeriodTrend — tampilkan hitungan per jam untuk daily maupun range
func GetPeriodTrend(mcuID int, dateFrom, dateTo string, isRange bool) ([]TrendPoint, error) {
_ = isRange
q := `
SELECT hour_no,
CONCAT(LPAD(hour_no, 2, '0'), ':00') AS label,
SUM(checked_in) AS checked_in,
SUM(checked_out) AS checked_out
FROM (
SELECT HOUR(Mcu_CheckinoutInTime) AS hour_no,
COUNT(*) AS checked_in,
0 AS checked_out
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutInTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
GROUP BY HOUR(Mcu_CheckinoutInTime)
UNION ALL
SELECT HOUR(Mcu_CheckinoutOutTime) AS hour_no,
0 AS checked_in,
COUNT(*) AS checked_out
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
GROUP BY HOUR(Mcu_CheckinoutOutTime)
) t
GROUP BY hour_no
ORDER BY hour_no
`
rows, err := db.DB.Query(q, mcuID, dateFrom, dateTo, mcuID, dateFrom, dateTo)
if err != nil {
return nil, err
}
defer rows.Close()
// Build cumulative
var points []TrendPoint
cumCI, cumCO := 0, 0
for rows.Next() {
var hourNo int
var label string
var ci, co int
if rows.Scan(&hourNo, &label, &ci, &co) == nil {
cumCI += ci
cumCO += co
points = append(points, TrendPoint{
Label: label,
CheckedIn: cumCI,
CheckedOut: cumCO,
})
}
}
return points, nil
}
type PatientStationStatus struct {
Station string
Done bool
ProcessAt string
DoneAt string
}
type PatientDetail struct {
ID int
Name string
Date string
InTime string
OutTime string
HasOut bool
Stations []PatientStationStatus
DoneCount int
}
type patientKey struct {
ID int
Date string
}
func GetAllPatients(mcuID int, dateFrom, dateTo string) ([]PatientDetail, error) {
rows, err := db.DB.Query(`
SELECT
mc.Mcu_CheckinoutPreregisterID,
mp.Mcu_PatientName,
DATE_FORMAT(mc.Mcu_CheckinoutDate, '%Y-%m-%d'),
DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'),
COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutOutTime, '%H:%i'), ''),
IF(mc.Mcu_CheckinoutOutTime IS NOT NULL, 1, 0),
rs.station_name,
IF(
(sp.Mcu_StationProgressSource = 'lab' AND sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressReceiveAt) <= mc.Mcu_CheckinoutDate)
OR (sp.Mcu_StationProgressSource = 'nonlab' AND sp.Mcu_StationProgressDoneAt IS NOT NULL AND DATE(sp.Mcu_StationProgressDoneAt) <= mc.Mcu_CheckinoutDate),
1, 0),
COALESCE(DATE_FORMAT(
CASE
WHEN sp.Mcu_StationProgressSource = 'lab'
THEN COALESCE(sp.Mcu_StationProgressSamplingAt, sp.Mcu_StationProgressProcessAt, sp.Mcu_StationProgressReceiveAt)
WHEN sp.Mcu_StationProgressSource = 'nonlab'
THEN COALESCE(sp.Mcu_StationProgressProcessAt, sp.Mcu_StationProgressDoneAt)
ELSE NULL
END,
'%d/%m/%Y %H:%i'), ''),
COALESCE(DATE_FORMAT(
IF(sp.Mcu_StationProgressSource = 'lab', sp.Mcu_StationProgressReceiveAt, sp.Mcu_StationProgressDoneAt),
'%d/%m/%Y %H:%i'), '')
FROM mcu_checkinout mc
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID
JOIN mcu_patient_required_station rs ON
rs.preregister_id = mc.Mcu_CheckinoutPreregisterID
AND rs.mcu_id = ?
LEFT JOIN mcu_station_progress sp ON
sp.Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
AND sp.Mcu_StationProgressStationID = rs.sample_station_id
AND sp.Mcu_StationProgressMcuID = ?
AND sp.Mcu_StationProgressCheckinDate = (
SELECT MAX(sp2.Mcu_StationProgressCheckinDate)
FROM mcu_station_progress sp2
WHERE sp2.Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
AND sp2.Mcu_StationProgressStationID = rs.sample_station_id
AND sp2.Mcu_StationProgressMcuID = ?
AND sp2.Mcu_StationProgressCheckinDate <= mc.Mcu_CheckinoutDate
)
WHERE mc.Mcu_CheckinoutMcuID = ?
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
AND mc.Mcu_CheckinoutIsActive = 'Y'
ORDER BY mc.Mcu_CheckinoutDate DESC, mc.Mcu_CheckinoutInTime DESC, rs.station_name
`, mcuID, mcuID, mcuID, mcuID, dateFrom, dateTo)
if err != nil {
return nil, err
}
defer rows.Close()
var patients []PatientDetail
keyIndex := map[patientKey]int{}
for rows.Next() {
var pid int
var name, date, inTime, outTime, stationName, processAt, doneAt string
var hasOutInt, doneInt int
if err := rows.Scan(&pid, &name, &date, &inTime, &outTime, &hasOutInt, &stationName, &doneInt, &processAt, &doneAt); err != nil {
continue
}
k := patientKey{ID: pid, Date: date}
idx, ok := keyIndex[k]
if !ok {
p := PatientDetail{
ID: pid,
Name: name,
Date: date,
InTime: inTime,
OutTime: outTime,
HasOut: hasOutInt == 1,
}
keyIndex[k] = len(patients)
patients = append(patients, p)
idx = len(patients) - 1
}
done := doneInt == 1
patients[idx].Stations = append(patients[idx].Stations, PatientStationStatus{
Station: stationName,
Done: done,
ProcessAt: processAt,
DoneAt: doneAt,
})
if done {
patients[idx].DoneCount++
}
}
return patients, nil
}

View File

@@ -0,0 +1,12 @@
package dashboard
import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) {
r.Get("/", Index)
r.Get("/stream", SSEStream) // SSE endpoint
r.Get("/kpi", KPI)
r.Get("/stations", Stations)
r.Get("/arrivals", Arrivals)
r.Get("/patients", Patients)
}

View File

@@ -0,0 +1,99 @@
package dashboard
import (
"bytes"
"fmt"
"net/http"
"strings"
"time"
)
type pollState struct {
kpiHash string
stationsHash string
arrivalsHash string
}
func formatSSE(event, html string) string {
data := strings.ReplaceAll(strings.TrimSpace(html), "\n", " ")
return fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)
}
func renderPartial(name string, data interface{}) string {
t := parse("templates/dashboard/partials/" + name + ".html")
var buf bytes.Buffer
t.ExecuteTemplate(&buf, name, data)
return buf.String()
}
func SSEStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // penting untuk nginx reverse proxy
project, _ := GetProject(activeMcuID(r))
if project.McuID == 0 {
return
}
availableDates, _ := GetCheckinDates(project.McuID)
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
isLive := mode == "daily" && dateFrom == time.Now().Format("2006-01-02")
var prev pollState
pushKPI := func(force bool) {
kpi, _ := GetKPI(project.McuID, dateFrom, dateTo)
key := fmt.Sprintf("%d|%d|%d", kpi.TotalStaff, kpi.CheckedIn, kpi.CheckedOut)
if force || key != prev.kpiHash {
fmt.Fprint(w, formatSSE("kpi", renderPartial("kpi", kpi)))
prev.kpiHash = key
}
}
pushStations := func(force bool) {
rows, _ := GetStations(project.McuID, dateFrom, dateTo)
key := fmt.Sprintf("%v", rows)
if force || key != prev.stationsHash {
fmt.Fprint(w, formatSSE("stations", renderPartial("stations", StationsPartial{Rows: rows, IsLive: isLive})))
prev.stationsHash = key
}
}
pushArrivals := func(force bool) {
rows, _ := GetArrivals(project.McuID, dateFrom, dateTo, 8)
key := fmt.Sprintf("%v", rows)
if force || key != prev.arrivalsHash {
fmt.Fprint(w, formatSSE("arrivals", renderPartial("arrivals", ArrivalsPartial{Rows: rows, IsLive: isLive})))
prev.arrivalsHash = key
}
}
// Kirim data langsung saat connect (force=true)
pushKPI(true)
pushStations(true)
pushArrivals(true)
flusher.Flush()
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
pushKPI(false)
pushStations(false)
pushArrivals(false)
flusher.Flush()
case <-r.Context().Done():
// Browser disconnect — goroutine ini langsung berhenti
return
}
}
}