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 }