Initial commit
This commit is contained in:
125
cpone-dashboard/menu/abnormal/handler.go
Normal file
125
cpone-dashboard/menu/abnormal/handler.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package abnormal
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var basePath string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Group string
|
||||
Groups []string
|
||||
Summary AbnormalSummary
|
||||
StaffJSON template.JS
|
||||
AgeJSON template.JS
|
||||
GenderJSON template.JS
|
||||
DeptJSON template.JS
|
||||
}
|
||||
|
||||
func toJS(v any) template.JS {
|
||||
b, _ := json.Marshal(v)
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil || !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
group := r.URL.Query().Get("group")
|
||||
|
||||
groups, err := GetAbnormalGroups(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
total, err := GetTotalPatients(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
abnormalCount, err := GetAbnormalCount(mcuID, group)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
normalCount := total - abnormalCount
|
||||
if normalCount < 0 {
|
||||
normalCount = 0
|
||||
}
|
||||
rate := 0
|
||||
if total > 0 {
|
||||
rate = abnormalCount * 100 / total
|
||||
}
|
||||
|
||||
ageData, err := GetAgeChartData(mcuID, group)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
genderData, err := GetGenderChartData(mcuID, group)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deptData, err := GetDeptChartData(mcuID, group)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Group: group,
|
||||
Groups: groups,
|
||||
Summary: AbnormalSummary{
|
||||
Total: total,
|
||||
Abnormal: abnormalCount,
|
||||
Normal: normalCount,
|
||||
AbnormalRate: rate,
|
||||
},
|
||||
StaffJSON: toJS(map[string]any{
|
||||
"normal": normalCount,
|
||||
"abnormal": abnormalCount,
|
||||
}),
|
||||
AgeJSON: toJS(map[string]any{
|
||||
"labels": ageData.Labels,
|
||||
"abnormal": ageData.Abnormal,
|
||||
}),
|
||||
GenderJSON: toJS(map[string]any{
|
||||
"labels": genderData.Labels,
|
||||
"abnormal": genderData.Abnormal,
|
||||
}),
|
||||
DeptJSON: toJS(map[string]any{
|
||||
"labels": deptData.Labels,
|
||||
"abnormal": deptData.Abnormal,
|
||||
}),
|
||||
}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
257
cpone-dashboard/menu/abnormal/query.go
Normal file
257
cpone-dashboard/menu/abnormal/query.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package abnormal
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
)
|
||||
|
||||
type AbnormalSummary struct {
|
||||
Total int
|
||||
Abnormal int
|
||||
Normal int
|
||||
AbnormalRate int // percentage, 0-100
|
||||
}
|
||||
|
||||
type AgeChartData struct {
|
||||
Labels []string
|
||||
Abnormal []int
|
||||
}
|
||||
|
||||
type GenderChartData struct {
|
||||
Labels []string
|
||||
Abnormal []int
|
||||
}
|
||||
|
||||
type DeptChartData struct {
|
||||
Labels []string
|
||||
Abnormal []int
|
||||
}
|
||||
|
||||
// GetTotalPatients returns total active patients for the project.
|
||||
func GetTotalPatients(mcuID int) (int, error) {
|
||||
var total int
|
||||
err := db.DB.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM mcu_patient
|
||||
WHERE Mcu_PatientMcuID = ?
|
||||
AND Mcu_PatientIsActive = 'Y'
|
||||
`, mcuID).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
// GetAbnormalCount returns distinct patients with at least one kelainan.
|
||||
// If group is non-empty, counts only patients with kelainan in that group.
|
||||
func GetAbnormalCount(mcuID int, group string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if group == "" {
|
||||
err = db.DB.QueryRow(`
|
||||
SELECT COUNT(DISTINCT M_PatientID)
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
`, mcuID).Scan(&count)
|
||||
} else {
|
||||
err = db.DB.QueryRow(`
|
||||
SELECT COUNT(DISTINCT M_PatientID)
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName = ?
|
||||
`, mcuID, group).Scan(&count)
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetAbnormalGroups returns distinct kelainan group names for a project.
|
||||
func GetAbnormalGroups(mcuID int) ([]string, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT DISTINCT Mcu_KelainanGroupSummaryName
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName IS NOT NULL
|
||||
AND Mcu_KelainanGroupSummaryName != ''
|
||||
ORDER BY Mcu_KelainanGroupSummaryName
|
||||
`, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []string
|
||||
for rows.Next() {
|
||||
var g string
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// GetAgeChartData returns abnormal patient counts by age bucket.
|
||||
func GetAgeChartData(mcuID int, group string) (AgeChartData, error) {
|
||||
buckets := []string{"<30", "30-39", "40-49", "50+"}
|
||||
counts := make(map[string]int)
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
if group == "" {
|
||||
query = `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN AgePatient < 30 THEN '<30'
|
||||
WHEN AgePatient < 40 THEN '30-39'
|
||||
WHEN AgePatient < 50 THEN '40-49'
|
||||
ELSE '50+'
|
||||
END AS age_group,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND AgePatient IS NOT NULL
|
||||
GROUP BY age_group`
|
||||
args = []any{mcuID}
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN AgePatient < 30 THEN '<30'
|
||||
WHEN AgePatient < 40 THEN '30-39'
|
||||
WHEN AgePatient < 50 THEN '40-49'
|
||||
ELSE '50+'
|
||||
END AS age_group,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName = ?
|
||||
AND AgePatient IS NOT NULL
|
||||
GROUP BY age_group`
|
||||
args = []any{mcuID, group}
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return AgeChartData{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var label string
|
||||
var cnt int
|
||||
if err := rows.Scan(&label, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
counts[label] = cnt
|
||||
}
|
||||
|
||||
data := AgeChartData{Labels: buckets, Abnormal: make([]int, len(buckets))}
|
||||
for i, b := range buckets {
|
||||
data.Abnormal[i] = counts[b]
|
||||
}
|
||||
return data, rows.Err()
|
||||
}
|
||||
|
||||
// GetGenderChartData returns abnormal patient counts by gender.
|
||||
func GetGenderChartData(mcuID int, group string) (GenderChartData, error) {
|
||||
labels := []string{"Male", "Female"}
|
||||
counts := make(map[string]int)
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
if group == "" {
|
||||
query = `
|
||||
SELECT
|
||||
CASE WHEN LOWER(M_PatientGender) = 'male' THEN 'Male' ELSE 'Female' END AS gender,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND M_PatientGender IS NOT NULL
|
||||
GROUP BY gender`
|
||||
args = []any{mcuID}
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
CASE WHEN LOWER(M_PatientGender) = 'male' THEN 'Male' ELSE 'Female' END AS gender,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName = ?
|
||||
AND M_PatientGender IS NOT NULL
|
||||
GROUP BY gender`
|
||||
args = []any{mcuID, group}
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return GenderChartData{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var label string
|
||||
var cnt int
|
||||
if err := rows.Scan(&label, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
counts[label] = cnt
|
||||
}
|
||||
|
||||
data := GenderChartData{Labels: labels, Abnormal: make([]int, len(labels))}
|
||||
for i, l := range labels {
|
||||
data.Abnormal[i] = counts[l]
|
||||
}
|
||||
return data, rows.Err()
|
||||
}
|
||||
|
||||
// GetDeptChartData returns top-10 departments by abnormal patient count.
|
||||
func GetDeptChartData(mcuID int, group string) (DeptChartData, error) {
|
||||
var query string
|
||||
var args []any
|
||||
if group == "" {
|
||||
query = `
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(TRIM(M_PatientDepartement), ''),
|
||||
NULLIF(TRIM(M_PatientDivisi), ''),
|
||||
NULLIF(TRIM(M_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS dept,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
GROUP BY dept
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10`
|
||||
args = []any{mcuID}
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(TRIM(M_PatientDepartement), ''),
|
||||
NULLIF(TRIM(M_PatientDivisi), ''),
|
||||
NULLIF(TRIM(M_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS dept,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName = ?
|
||||
GROUP BY dept
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10`
|
||||
args = []any{mcuID, group}
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return DeptChartData{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var data DeptChartData
|
||||
for rows.Next() {
|
||||
var label string
|
||||
var cnt int
|
||||
if err := rows.Scan(&label, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
data.Labels = append(data.Labels, label)
|
||||
data.Abnormal = append(data.Abnormal, cnt)
|
||||
}
|
||||
return data, rows.Err()
|
||||
}
|
||||
7
cpone-dashboard/menu/abnormal/route.go
Normal file
7
cpone-dashboard/menu/abnormal/route.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package abnormal
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
}
|
||||
128
cpone-dashboard/menu/arrival/handler.go
Normal file
128
cpone-dashboard/menu/arrival/handler.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package arrival
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var basePath string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Date string
|
||||
AvailableDates []string
|
||||
Search string
|
||||
Department string
|
||||
Rows []ArrivalRow
|
||||
FilteredRows []ArrivalRow
|
||||
Summary ArrivalSummary
|
||||
Departments []DepartmentStat
|
||||
DepartmentOptions []string
|
||||
OverviewJSON template.JS
|
||||
DepartmentJSON template.JS
|
||||
}
|
||||
|
||||
func toJS(v interface{}) template.JS {
|
||||
b, _ := json.Marshal(v)
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
dates, err := GetArrivalDates(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
selectedDate := activeDateOrLatest(dates, r.URL.Query().Get("date"), project.StartDate)
|
||||
rows, err := GetArrivalRows(mcuID, selectedDate)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
summary, deptStats := BuildArrivalStats(rows)
|
||||
filteredRows := FilterArrivalRows(rows, r.URL.Query().Get("search"), r.URL.Query().Get("dept"))
|
||||
deptOptions := UniqueDepartments(rows)
|
||||
|
||||
stationMap, err := GetStationProgress(mcuID, selectedDate)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i := range filteredRows {
|
||||
filteredRows[i].Stations = stationMap[filteredRows[i].PreregisterID]
|
||||
}
|
||||
|
||||
// Chart 1: double donut — inner: checked-in vs pending, outer: total per posisi/dept
|
||||
outerDepts := []map[string]any{}
|
||||
for _, d := range deptStats {
|
||||
if d.Total > 0 {
|
||||
outerDepts = append(outerDepts, map[string]any{"name": d.Name, "value": d.Total})
|
||||
}
|
||||
}
|
||||
overview := map[string]any{
|
||||
"checkedIn": summary.CheckedIn,
|
||||
"pending": summary.Pending,
|
||||
"depts": outerDepts,
|
||||
}
|
||||
|
||||
// Chart 2: per-station patient count bar chart
|
||||
stationStats := BuildStationChart(stationMap)
|
||||
stationLabels := make([]string, len(stationStats))
|
||||
stationCounts := make([]int, len(stationStats))
|
||||
for i, s := range stationStats {
|
||||
stationLabels[i] = s.Name
|
||||
stationCounts[i] = s.Count
|
||||
}
|
||||
stationChart := map[string]any{
|
||||
"labels": stationLabels,
|
||||
"counts": stationCounts,
|
||||
}
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Date: selectedDate,
|
||||
AvailableDates: dates,
|
||||
Search: r.URL.Query().Get("search"),
|
||||
Department: r.URL.Query().Get("dept"),
|
||||
Rows: rows,
|
||||
FilteredRows: filteredRows,
|
||||
Summary: summary,
|
||||
Departments: deptStats,
|
||||
DepartmentOptions: deptOptions,
|
||||
OverviewJSON: toJS(overview),
|
||||
DepartmentJSON: toJS(stationChart),
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
307
cpone-dashboard/menu/arrival/query.go
Normal file
307
cpone-dashboard/menu/arrival/query.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package arrival
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StationBadge struct {
|
||||
Name string
|
||||
Tone string // "success" | "warning" | "danger" | "neutral"
|
||||
}
|
||||
|
||||
type StationStat struct {
|
||||
Name string
|
||||
Count int
|
||||
}
|
||||
|
||||
type ArrivalRow struct {
|
||||
PreregisterID int
|
||||
NIP string
|
||||
Name string
|
||||
Department string
|
||||
InTime string
|
||||
Status string
|
||||
StatusTone string
|
||||
Stations []StationBadge
|
||||
}
|
||||
|
||||
type DepartmentStat struct {
|
||||
Name string
|
||||
CheckedIn int
|
||||
Pending int
|
||||
Total int
|
||||
}
|
||||
|
||||
type ArrivalSummary struct {
|
||||
CheckedIn int
|
||||
Pending int
|
||||
Total int
|
||||
}
|
||||
|
||||
func GetArrivalDates(mcuID int) ([]string, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT DATE_FORMAT(Mcu_PatientScheduleDate, '%Y-%m-%d') AS schedule_date
|
||||
FROM mcu_patient_schedule
|
||||
WHERE Mcu_PatientSchedulePreregisterID IN (
|
||||
SELECT Mcu_PatientPreregisterID
|
||||
FROM mcu_patient
|
||||
WHERE Mcu_PatientMcuID = ?
|
||||
AND Mcu_PatientIsActive = 'Y'
|
||||
)
|
||||
AND Mcu_PatientScheduleIsActive = 'Y'
|
||||
GROUP BY Mcu_PatientScheduleDate
|
||||
ORDER BY Mcu_PatientScheduleDate 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 GetArrivalRows(mcuID int, date string) ([]ArrivalRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
mp.Mcu_PatientPreregisterID,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '') AS nip,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '') AS patient_name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS department_name,
|
||||
COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'), '') AS in_time,
|
||||
CASE
|
||||
WHEN mc.Mcu_CheckinoutOutTime IS NOT NULL THEN 'Performed'
|
||||
WHEN mc.Mcu_CheckinoutInTime IS NOT NULL THEN 'In Progress'
|
||||
ELSE 'Not Check-in Yet'
|
||||
END AS status_text,
|
||||
CASE
|
||||
WHEN mc.Mcu_CheckinoutOutTime IS NOT NULL THEN 'success'
|
||||
WHEN mc.Mcu_CheckinoutInTime IS NOT NULL THEN 'warning'
|
||||
ELSE 'neutral'
|
||||
END AS status_tone
|
||||
FROM mcu_patient_schedule s
|
||||
JOIN mcu_patient mp
|
||||
ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID
|
||||
AND mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
LEFT JOIN mcu_checkinout mc
|
||||
ON mc.Mcu_CheckinoutPreregisterID = mp.Mcu_PatientPreregisterID
|
||||
AND mc.Mcu_CheckinoutMcuID = ?
|
||||
AND mc.Mcu_CheckinoutDate = s.Mcu_PatientScheduleDate
|
||||
AND mc.Mcu_CheckinoutIsActive = 'Y'
|
||||
WHERE s.Mcu_PatientScheduleIsActive = 'Y'
|
||||
AND s.Mcu_PatientScheduleDate = ?
|
||||
ORDER BY
|
||||
CASE WHEN mc.Mcu_CheckinoutInTime IS NULL THEN 1 ELSE 0 END,
|
||||
mc.Mcu_CheckinoutInTime DESC,
|
||||
mp.Mcu_PatientName ASC
|
||||
`, mcuID, mcuID, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ArrivalRow
|
||||
for rows.Next() {
|
||||
var r ArrivalRow
|
||||
if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Department, &r.InTime, &r.Status, &r.StatusTone); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(r.NIP) == "" {
|
||||
r.NIP = "-"
|
||||
}
|
||||
if strings.TrimSpace(r.Name) == "" {
|
||||
r.Name = "-"
|
||||
}
|
||||
if strings.TrimSpace(r.Department) == "" {
|
||||
r.Department = "-"
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetStationProgress(mcuID int, date string) (map[int][]StationBadge, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
sp.Mcu_StationProgressPreregisterID,
|
||||
sp.Mcu_StationProgressStationName,
|
||||
CASE
|
||||
WHEN sp.Mcu_StationProgressDoneAt IS NOT NULL THEN 'success'
|
||||
WHEN sp.Mcu_StationProgressProcessAt IS NOT NULL
|
||||
OR sp.Mcu_StationProgressReceiveAt IS NOT NULL
|
||||
OR sp.Mcu_StationProgressSamplingAt IS NOT NULL THEN 'warning'
|
||||
ELSE 'neutral'
|
||||
END AS tone
|
||||
FROM mcu_station_progress sp
|
||||
WHERE sp.Mcu_StationProgressMcuID = ?
|
||||
AND sp.Mcu_StationProgressPreregisterID IN (
|
||||
SELECT mp.Mcu_PatientPreregisterID
|
||||
FROM mcu_patient_schedule s
|
||||
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID
|
||||
WHERE mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
AND s.Mcu_PatientScheduleDate = ?
|
||||
AND s.Mcu_PatientScheduleIsActive = 'Y'
|
||||
)
|
||||
ORDER BY sp.Mcu_StationProgressPreregisterID, sp.Mcu_StationProgressStationName
|
||||
`, mcuID, mcuID, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := map[int][]StationBadge{}
|
||||
for rows.Next() {
|
||||
var preregID int
|
||||
var name, tone string
|
||||
if err := rows.Scan(&preregID, &name, &tone); err != nil {
|
||||
continue
|
||||
}
|
||||
result[preregID] = append(result[preregID], StationBadge{Name: name, Tone: tone})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildArrivalStats(rows []ArrivalRow) (ArrivalSummary, []DepartmentStat) {
|
||||
summary := ArrivalSummary{Total: len(rows)}
|
||||
deptMap := map[string]*DepartmentStat{}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.InTime != "" {
|
||||
summary.CheckedIn++
|
||||
}
|
||||
dept := row.Department
|
||||
if dept == "" {
|
||||
dept = "-"
|
||||
}
|
||||
stat, ok := deptMap[dept]
|
||||
if !ok {
|
||||
stat = &DepartmentStat{Name: dept}
|
||||
deptMap[dept] = stat
|
||||
}
|
||||
stat.Total++
|
||||
if row.InTime != "" {
|
||||
stat.CheckedIn++
|
||||
}
|
||||
}
|
||||
|
||||
summary.Pending = summary.Total - summary.CheckedIn
|
||||
if summary.Pending < 0 {
|
||||
summary.Pending = 0
|
||||
}
|
||||
|
||||
stats := make([]DepartmentStat, 0, len(deptMap))
|
||||
for _, stat := range deptMap {
|
||||
stat.Pending = stat.Total - stat.CheckedIn
|
||||
if stat.Pending < 0 {
|
||||
stat.Pending = 0
|
||||
}
|
||||
stats = append(stats, *stat)
|
||||
}
|
||||
sort.Slice(stats, func(i, j int) bool {
|
||||
if stats[i].CheckedIn != stats[j].CheckedIn {
|
||||
return stats[i].CheckedIn > stats[j].CheckedIn
|
||||
}
|
||||
if stats[i].Total != stats[j].Total {
|
||||
return stats[i].Total > stats[j].Total
|
||||
}
|
||||
return stats[i].Name < stats[j].Name
|
||||
})
|
||||
|
||||
return summary, stats
|
||||
}
|
||||
|
||||
func BuildStationChart(stationMap map[int][]StationBadge) []StationStat {
|
||||
countMap := map[string]int{}
|
||||
for _, badges := range stationMap {
|
||||
for _, b := range badges {
|
||||
countMap[b.Name]++
|
||||
}
|
||||
}
|
||||
stats := make([]StationStat, 0, len(countMap))
|
||||
for name, count := range countMap {
|
||||
stats = append(stats, StationStat{Name: name, Count: count})
|
||||
}
|
||||
sort.Slice(stats, func(i, j int) bool {
|
||||
return stats[i].Count > stats[j].Count
|
||||
})
|
||||
return stats
|
||||
}
|
||||
|
||||
func FilterArrivalRows(rows []ArrivalRow, search, dept string) []ArrivalRow {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
dept = strings.TrimSpace(dept)
|
||||
if search == "" && dept == "" {
|
||||
return rows
|
||||
}
|
||||
|
||||
out := make([]ArrivalRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if dept != "" && dept != "All Departments" && row.Department != dept {
|
||||
continue
|
||||
}
|
||||
if search != "" {
|
||||
hay := strings.ToLower(row.Name + " " + row.NIP + " " + row.Department)
|
||||
if !strings.Contains(hay, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func UniqueDepartments(rows []ArrivalRow) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, row := range rows {
|
||||
name := strings.TrimSpace(row.Department)
|
||||
if name == "" {
|
||||
name = "-"
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func activeDateOrLatest(dates []string, selected string, fallback string) string {
|
||||
selected = strings.TrimSpace(selected)
|
||||
if selected != "" {
|
||||
for _, d := range dates {
|
||||
if d == selected {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dates) > 0 {
|
||||
return dates[0]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func mustDayLabel(date string) string {
|
||||
if len(date) >= 10 {
|
||||
return fmt.Sprintf("%s/%s/%s", date[8:10], date[5:7], date[0:4])
|
||||
}
|
||||
return date
|
||||
}
|
||||
7
cpone-dashboard/menu/arrival/route.go
Normal file
7
cpone-dashboard/menu/arrival/route.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package arrival
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
}
|
||||
69
cpone-dashboard/menu/auth/handler.go
Normal file
69
cpone-dashboard/menu/auth/handler.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
tmplFS *embed.FS
|
||||
authSecret string
|
||||
basePath string
|
||||
)
|
||||
|
||||
func Init(fs *embed.FS, secret string) {
|
||||
tmplFS = fs
|
||||
authSecret = secret
|
||||
}
|
||||
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type loginData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
func ShowLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if getSession(r, authSecret) != "" {
|
||||
http.Redirect(w, r, basePath+"/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
render(w, loginData{})
|
||||
}
|
||||
|
||||
func DoLogin(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
var hash, salt string
|
||||
err := db.DB.QueryRow(
|
||||
`SELECT User_Password, COALESCE(User_Salt, '')
|
||||
FROM dashboard_user
|
||||
WHERE User_Username = ? AND User_IsActive = 'Y'`,
|
||||
username,
|
||||
).Scan(&hash, &salt)
|
||||
if err != nil || !checkPassword(password, salt, hash) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
render(w, loginData{Error: "Username atau password salah."})
|
||||
return
|
||||
}
|
||||
|
||||
SetSession(w, username, authSecret)
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func DoLogout(w http.ResponseWriter, r *http.Request) {
|
||||
ClearSession(w)
|
||||
http.Redirect(w, r, basePath+"/mcu-login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func checkPassword(password, salt, storedHash string) bool {
|
||||
return hashPassword(password, salt) == storedHash
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, data loginData) {
|
||||
b := func(path string) string { return basePath + path }
|
||||
t := template.Must(template.New("").Funcs(template.FuncMap{"b": b}).ParseFS(tmplFS, "templates/login/index.html"))
|
||||
t.ExecuteTemplate(w, "login", data)
|
||||
}
|
||||
16
cpone-dashboard/menu/auth/middleware.go
Normal file
16
cpone-dashboard/menu/auth/middleware.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
import "net/http"
|
||||
|
||||
func Require(secret string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username := getSession(r, secret)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, basePath+"/mcu-login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, setContext(r, username))
|
||||
})
|
||||
}
|
||||
}
|
||||
79
cpone-dashboard/menu/auth/password.go
Normal file
79
cpone-dashboard/menu/auth/password.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type passwordData struct {
|
||||
Username string
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
||||
func ShowPassword(w http.ResponseWriter, r *http.Request) {
|
||||
renderPassword(w, passwordData{Username: Username(r)})
|
||||
}
|
||||
|
||||
func DoChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
username := Username(r)
|
||||
current := r.FormValue("current_password")
|
||||
newPass := r.FormValue("new_password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
fail := func(msg string) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
renderPassword(w, passwordData{Username: username, Error: msg})
|
||||
}
|
||||
|
||||
if newPass != confirm {
|
||||
fail("Password baru dan konfirmasi tidak cocok.")
|
||||
return
|
||||
}
|
||||
if len(newPass) < 6 {
|
||||
fail("Password baru minimal 6 karakter.")
|
||||
return
|
||||
}
|
||||
|
||||
// Verifikasi password lama
|
||||
var storedHash, salt string
|
||||
err := db.DB.QueryRow(
|
||||
`SELECT User_Password, COALESCE(User_Salt, '')
|
||||
FROM dashboard_user WHERE User_Username = ? AND User_IsActive = 'Y'`,
|
||||
username,
|
||||
).Scan(&storedHash, &salt)
|
||||
if err != nil || !checkPassword(current, salt, storedHash) {
|
||||
fail("Password saat ini tidak sesuai.")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate salt + hash baru
|
||||
newSalt := newUUID()
|
||||
newHash := hashPassword(newPass, newSalt)
|
||||
|
||||
_, err = db.DB.Exec(
|
||||
`UPDATE dashboard_user
|
||||
SET User_Password = ?, User_Salt = ?, User_UpdatedAt = NOW()
|
||||
WHERE User_Username = ?`,
|
||||
newHash, newSalt, username,
|
||||
)
|
||||
if err != nil {
|
||||
fail("Gagal menyimpan password baru, coba lagi.")
|
||||
return
|
||||
}
|
||||
|
||||
renderPassword(w, passwordData{Username: username, Success: "Password berhasil diubah."})
|
||||
}
|
||||
|
||||
func renderPassword(w http.ResponseWriter, data passwordData) {
|
||||
t := template.Must(template.ParseFS(tmplFS, "templates/auth/password.html"))
|
||||
t.ExecuteTemplate(w, "password", data)
|
||||
}
|
||||
|
||||
func hashPassword(password, salt string) string {
|
||||
h := sha256.Sum256([]byte(salt + ":" + password))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
14
cpone-dashboard/menu/auth/route.go
Normal file
14
cpone-dashboard/menu/auth/route.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/mcu-login", ShowLogin)
|
||||
r.Post("/mcu-login", DoLogin)
|
||||
r.Get("/logout", DoLogout)
|
||||
}
|
||||
|
||||
func ProtectedRoutes(r chi.Router) {
|
||||
r.Get("/password", ShowPassword)
|
||||
r.Post("/password", DoChangePassword)
|
||||
}
|
||||
124
cpone-dashboard/menu/auth/session.go
Normal file
124
cpone-dashboard/menu/auth/session.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cookieName = "cpone_session"
|
||||
const projectCookieName = "cpone_project_mcu_id"
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const userCtxKey ctxKey = "username"
|
||||
|
||||
// Username returns the logged-in username from the request context.
|
||||
func Username(r *http.Request) string {
|
||||
v, _ := r.Context().Value(userCtxKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func setContext(r *http.Request, username string) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), userCtxKey, username))
|
||||
}
|
||||
|
||||
func SetSession(w http.ResponseWriter, username, secret string) {
|
||||
val := encode(username) + "." + sign(username, secret)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: val,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func SetSelectedProject(w http.ResponseWriter, mcuID int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: projectCookieName,
|
||||
Value: strconv.Itoa(mcuID),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSelectedProject(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: projectCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func SelectedProjectID(r *http.Request) int {
|
||||
c, err := r.Cookie(projectCookieName)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimSpace(c.Value))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func getSession(r *http.Request, secret string) string {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(c.Value, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
username, err := decode(parts[0])
|
||||
if err != nil || username == "" {
|
||||
return ""
|
||||
}
|
||||
if !hmac.Equal([]byte(sign(username, secret)), []byte(parts[1])) {
|
||||
return ""
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func sign(username, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(username))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func encode(s string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func decode(s string) (string, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
return string(b), err
|
||||
}
|
||||
287
cpone-dashboard/menu/dashboard/handler.go
Normal file
287
cpone-dashboard/menu/dashboard/handler.go
Normal 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)
|
||||
}
|
||||
505
cpone-dashboard/menu/dashboard/query.go
Normal file
505
cpone-dashboard/menu/dashboard/query.go
Normal 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
|
||||
}
|
||||
12
cpone-dashboard/menu/dashboard/route.go
Normal file
12
cpone-dashboard/menu/dashboard/route.go
Normal 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)
|
||||
}
|
||||
99
cpone-dashboard/menu/dashboard/sse.go
Normal file
99
cpone-dashboard/menu/dashboard/sse.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
74
cpone-dashboard/menu/progress/handler.go
Normal file
74
cpone-dashboard/menu/progress/handler.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var basePath string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Search string
|
||||
Status string
|
||||
Rows []ProgressRow
|
||||
FilteredRows []ProgressRow
|
||||
Summary ProgressSummary
|
||||
ValidatedPct int
|
||||
PublishedPct int
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := GetProgressRows(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
summary := BuildProgressSummary(rows)
|
||||
search := r.URL.Query().Get("search")
|
||||
status := r.URL.Query().Get("status")
|
||||
filteredRows := FilterProgressRows(rows, search, status)
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Search: search,
|
||||
Status: status,
|
||||
Rows: rows,
|
||||
FilteredRows: filteredRows,
|
||||
Summary: summary,
|
||||
ValidatedPct: Pct(summary.Validated, summary.Total),
|
||||
PublishedPct: Pct(summary.Published, summary.Total),
|
||||
}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
120
cpone-dashboard/menu/progress/query.go
Normal file
120
cpone-dashboard/menu/progress/query.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProgressRow struct {
|
||||
PreregisterID int
|
||||
NIP string
|
||||
Name string
|
||||
Posisi string
|
||||
ResumeStatus string
|
||||
Validated string
|
||||
Published string
|
||||
}
|
||||
|
||||
type ProgressSummary struct {
|
||||
Total int
|
||||
Validated int
|
||||
Published int
|
||||
}
|
||||
|
||||
func GetProgressRows(mcuID int) ([]ProgressRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
mp.Mcu_PatientPreregisterID,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS posisi,
|
||||
COALESCE(rs.Mcu_PatientResumeStatusStatus, '') AS resume_status,
|
||||
COALESCE(rs.Mcu_PatientResumeStatusValidated, 'N') AS validated,
|
||||
COALESCE(rs.Mcu_PatientResumeStatusPublished, 'N') AS published
|
||||
FROM mcu_patient mp
|
||||
LEFT JOIN mcu_patient_resume_status rs
|
||||
ON rs.Mcu_PatientResumeStatusPreregisterID = mp.Mcu_PatientPreregisterID
|
||||
AND rs.Mcu_PatientResumeStatusMcuID = ?
|
||||
WHERE mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
ORDER BY
|
||||
rs.Mcu_PatientResumeStatusValidated DESC,
|
||||
mp.Mcu_PatientName ASC
|
||||
`, mcuID, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ProgressRow
|
||||
for rows.Next() {
|
||||
var r ProgressRow
|
||||
if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Posisi, &r.ResumeStatus, &r.Validated, &r.Published); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildProgressSummary(rows []ProgressRow) ProgressSummary {
|
||||
s := ProgressSummary{Total: len(rows)}
|
||||
for _, r := range rows {
|
||||
if r.Validated == "Y" {
|
||||
s.Validated++
|
||||
}
|
||||
if r.Published == "Y" {
|
||||
s.Published++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func FilterProgressRows(rows []ProgressRow, search, status string) []ProgressRow {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
status = strings.TrimSpace(status)
|
||||
if search == "" && status == "" {
|
||||
return rows
|
||||
}
|
||||
out := make([]ProgressRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch status {
|
||||
case "validated":
|
||||
if r.Validated != "Y" {
|
||||
continue
|
||||
}
|
||||
case "published":
|
||||
if r.Published != "Y" {
|
||||
continue
|
||||
}
|
||||
case "not_validated":
|
||||
if r.Validated == "Y" {
|
||||
continue
|
||||
}
|
||||
case "not_published":
|
||||
if r.Published == "Y" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi)
|
||||
if !strings.Contains(hay, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func Pct(num, total int) int {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return num * 100 / total
|
||||
}
|
||||
7
cpone-dashboard/menu/progress/route.go
Normal file
7
cpone-dashboard/menu/progress/route.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package progress
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
}
|
||||
71
cpone-dashboard/menu/projects/handler.go
Normal file
71
cpone-dashboard/menu/projects/handler.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var tmplFS *embed.FS
|
||||
var basePath string
|
||||
|
||||
func SetTemplateFS(fs *embed.FS) { tmplFS = fs }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
Projects []ProjectItem
|
||||
CurrentProject *ProjectItem
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
selectedID := auth.SelectedProjectID(r)
|
||||
|
||||
items, err := GetUserProjects(username)
|
||||
if err != nil {
|
||||
log.Printf("[projects] GetUserProjects error: %v", err)
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
b := func(path string) string { return basePath + path }
|
||||
t := template.Must(template.New("").Funcs(template.FuncMap{"b": b}).ParseFS(tmplFS, "templates/projects/index.html"))
|
||||
var current *ProjectItem
|
||||
if selectedID > 0 {
|
||||
if item, ok, _ := GetUserProject(username, selectedID); ok {
|
||||
current = &item
|
||||
}
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "projects", pageData{
|
||||
Username: username,
|
||||
Projects: items,
|
||||
CurrentProject: current,
|
||||
}); err != nil {
|
||||
log.Printf("[projects] template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Select(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID, _ := strconv.Atoi(r.URL.Query().Get("mcu_id"))
|
||||
if mcuID <= 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok, err := GetUserProject(username, mcuID); err != nil {
|
||||
log.Printf("[projects] GetUserProject error: %v", err)
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
auth.SetSelectedProject(w, mcuID)
|
||||
http.Redirect(w, r, basePath+"/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
93
cpone-dashboard/menu/projects/query.go
Normal file
93
cpone-dashboard/menu/projects/query.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"cpone-dashboard/menu/auth"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ProjectItem struct {
|
||||
McuID int
|
||||
Label string
|
||||
CorporateName string
|
||||
Number string
|
||||
StartDate string
|
||||
EndDate string
|
||||
TotalParticipant int
|
||||
}
|
||||
|
||||
func HasAccess(username string, mcuID int) (bool, error) {
|
||||
var count int
|
||||
err := db.DB.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM dashboard_user_project up
|
||||
JOIN dashboard_user u ON u.User_ID = up.UserProj_UserID
|
||||
WHERE u.User_Username = ?
|
||||
AND up.UserProj_McuID = ?
|
||||
AND up.UserProj_IsActive = 'Y'
|
||||
`, username, mcuID).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func GetUserProjects(username string) ([]ProjectItem, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
p.Mcu_ProjectMcuID,
|
||||
COALESCE(p.Mcu_ProjectLabel, ''),
|
||||
COALESCE(p.Mcu_ProjectCorporateName, ''),
|
||||
COALESCE(p.Mcu_ProjectNumber, ''),
|
||||
COALESCE(DATE_FORMAT(p.Mcu_ProjectStartDate, '%d/%m/%Y'), ''),
|
||||
COALESCE(DATE_FORMAT(p.Mcu_ProjectEndDate, '%d/%m/%Y'), ''),
|
||||
p.Mcu_ProjectTotalParticipant
|
||||
FROM dashboard_user_project up
|
||||
JOIN dashboard_user u ON u.User_ID = up.UserProj_UserID
|
||||
JOIN mcu_project p ON p.Mcu_ProjectMcuID = up.UserProj_McuID
|
||||
WHERE u.User_Username = ?
|
||||
AND up.UserProj_IsActive = 'Y'
|
||||
AND p.Mcu_ProjectIsActive = 'Y'
|
||||
ORDER BY p.Mcu_ProjectStartDate DESC
|
||||
`, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ProjectItem
|
||||
for rows.Next() {
|
||||
var item ProjectItem
|
||||
if err := rows.Scan(
|
||||
&item.McuID,
|
||||
&item.Label,
|
||||
&item.CorporateName,
|
||||
&item.Number,
|
||||
&item.StartDate,
|
||||
&item.EndDate,
|
||||
&item.TotalParticipant,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func GetUserProject(username string, mcuID int) (ProjectItem, bool, error) {
|
||||
items, err := GetUserProjects(username)
|
||||
if err != nil {
|
||||
return ProjectItem{}, false, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.McuID == mcuID {
|
||||
return item, true, nil
|
||||
}
|
||||
}
|
||||
return ProjectItem{}, false, nil
|
||||
}
|
||||
|
||||
func ResolveCurrentProject(username string, r *http.Request) (ProjectItem, bool, error) {
|
||||
selectedID := auth.SelectedProjectID(r)
|
||||
if selectedID == 0 {
|
||||
return ProjectItem{}, false, nil
|
||||
}
|
||||
return GetUserProject(username, selectedID)
|
||||
}
|
||||
8
cpone-dashboard/menu/projects/route.go
Normal file
8
cpone-dashboard/menu/projects/route.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package projects
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
r.Get("/select", Select)
|
||||
}
|
||||
74
cpone-dashboard/menu/result/handler.go
Normal file
74
cpone-dashboard/menu/result/handler.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package result
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var pdfBaseURL string
|
||||
var basePath string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetPDFBaseURL(u string) { pdfBaseURL = u }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Search string
|
||||
Filter string
|
||||
Rows []ResultRow
|
||||
FilteredRows []ResultRow
|
||||
Summary ResultSummary
|
||||
PDFBaseURL string
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := GetResultRows(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
summary := BuildResultSummary(rows)
|
||||
search := r.URL.Query().Get("search")
|
||||
filter := r.URL.Query().Get("filter")
|
||||
filteredRows := FilterResultRows(rows, search, filter)
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Search: search,
|
||||
Filter: filter,
|
||||
Rows: rows,
|
||||
FilteredRows: filteredRows,
|
||||
Summary: summary,
|
||||
PDFBaseURL: pdfBaseURL,
|
||||
}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
101
cpone-dashboard/menu/result/query.go
Normal file
101
cpone-dashboard/menu/result/query.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package result
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ResultRow struct {
|
||||
NIP string
|
||||
Name string
|
||||
Posisi string
|
||||
FileUrl string
|
||||
ReportDate string
|
||||
}
|
||||
|
||||
type ResultSummary struct {
|
||||
Total int
|
||||
HasPDF int
|
||||
}
|
||||
|
||||
func GetResultRows(mcuID int) ([]ResultRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS posisi,
|
||||
COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url,
|
||||
CASE
|
||||
WHEN p.Published_McuDasboardFileUrl IS NOT NULL
|
||||
AND p.Published_McuDasboardFileUrl != ''
|
||||
THEN COALESCE(CAST(p.Published_McuDasboardLastUpdated AS CHAR), '')
|
||||
ELSE ''
|
||||
END AS report_date
|
||||
FROM mcu_patient mp
|
||||
LEFT JOIN published_mcu_dashboard_sync p
|
||||
ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID
|
||||
WHERE mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
ORDER BY
|
||||
(p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != '') DESC,
|
||||
mp.Mcu_PatientName ASC
|
||||
`, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ResultRow
|
||||
for rows.Next() {
|
||||
var r ResultRow
|
||||
if err := rows.Scan(&r.NIP, &r.Name, &r.Posisi, &r.FileUrl, &r.ReportDate); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func BuildResultSummary(rows []ResultRow) ResultSummary {
|
||||
s := ResultSummary{Total: len(rows)}
|
||||
for _, r := range rows {
|
||||
if r.FileUrl != "" {
|
||||
s.HasPDF++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
filter = strings.TrimSpace(filter)
|
||||
if search == "" && filter == "" {
|
||||
return rows
|
||||
}
|
||||
out := make([]ResultRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch filter {
|
||||
case "has_pdf":
|
||||
if r.FileUrl == "" {
|
||||
continue
|
||||
}
|
||||
case "no_pdf":
|
||||
if r.FileUrl != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi)
|
||||
if !strings.Contains(hay, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
7
cpone-dashboard/menu/result/route.go
Normal file
7
cpone-dashboard/menu/result/route.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package result
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
}
|
||||
Reference in New Issue
Block a user