Initial commit
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user