Improve mobile UI and add infinite scroll

This commit is contained in:
sas.fajri
2026-05-04 10:44:38 +07:00
parent b07f04217e
commit 7c378f78bf
17 changed files with 729 additions and 115 deletions

View File

@@ -38,10 +38,10 @@ BEGIN
ORDER BY mn.Mcu_NumberID DESC ORDER BY mn.Mcu_NumberID DESC
LIMIT 1; LIMIT 1;
DELETE FROM cpone_corporate.kelainan_details DELETE FROM cpone_dashboard.kelainan_details
WHERE T_OrderHeaderID = p_order_header_id; WHERE T_OrderHeaderID = p_order_header_id;
INSERT INTO cpone_corporate.kelainan_details ( INSERT INTO cpone_dashboard.kelainan_details (
Numbering, Tx_KelainanID, Tx_Type, T_OrderHeaderID, T_OrderHeaderDate, T_OrderHeaderLabNumber, Numbering, Tx_KelainanID, Tx_Type, T_OrderHeaderID, T_OrderHeaderDate, T_OrderHeaderLabNumber,
AgePatient, M_PatientID, M_PatientNoReg, M_PatientDOB, M_PatientGender, M_PatientIdentifierValue, AgePatient, M_PatientID, M_PatientNoReg, M_PatientDOB, M_PatientGender, M_PatientIdentifierValue,
M_PatientNIP, M_PatientJob, M_PatientPosisi, M_PatientDivisi, M_PatientLocation, M_PatientDepartement, M_PatientNIP, M_PatientJob, M_PatientPosisi, M_PatientDivisi, M_PatientLocation, M_PatientDepartement,
@@ -75,7 +75,7 @@ BEGIN
LEFT JOIN cpone.m_title ON M_PatientM_TitleID=M_TitleID AND M_TitleIsActive='Y' LEFT JOIN cpone.m_title ON M_PatientM_TitleID=M_TitleID AND M_TitleIsActive='Y'
WHERE T_KelainanNonLabIsActive='Y' AND T_OrderHeaderID=p_order_header_id; WHERE T_KelainanNonLabIsActive='Y' AND T_OrderHeaderID=p_order_header_id;
INSERT INTO cpone_corporate.kelainan_details ( INSERT INTO cpone_dashboard.kelainan_details (
Numbering, Tx_KelainanID, Tx_Type, T_OrderHeaderID, T_OrderHeaderDate, T_OrderHeaderLabNumber, Numbering, Tx_KelainanID, Tx_Type, T_OrderHeaderID, T_OrderHeaderDate, T_OrderHeaderLabNumber,
AgePatient, M_PatientID, M_PatientNoReg, M_PatientDOB, M_PatientGender, M_PatientIdentifierValue, AgePatient, M_PatientID, M_PatientNoReg, M_PatientDOB, M_PatientGender, M_PatientIdentifierValue,
M_PatientNIP, M_PatientJob, M_PatientPosisi, M_PatientDivisi, M_PatientLocation, M_PatientDepartement, M_PatientNIP, M_PatientJob, M_PatientPosisi, M_PatientDivisi, M_PatientLocation, M_PatientDepartement,
@@ -108,7 +108,7 @@ BEGIN
WHERE T_KelainanLabIsActive='Y' AND T_OrderHeaderID=p_order_header_id WHERE T_KelainanLabIsActive='Y' AND T_OrderHeaderID=p_order_header_id
GROUP BY T_KelainanLabID; GROUP BY T_KelainanLabID;
INSERT INTO cpone_corporate.kelainan_details ( INSERT INTO cpone_dashboard.kelainan_details (
Numbering, Tx_KelainanID, Tx_Type, T_OrderHeaderID, T_OrderHeaderDate, T_OrderHeaderLabNumber, Numbering, Tx_KelainanID, Tx_Type, T_OrderHeaderID, T_OrderHeaderDate, T_OrderHeaderLabNumber,
AgePatient, M_PatientID, M_PatientNoReg, M_PatientDOB, M_PatientGender, M_PatientIdentifierValue, AgePatient, M_PatientID, M_PatientNoReg, M_PatientDOB, M_PatientGender, M_PatientIdentifierValue,
M_PatientNIP, M_PatientJob, M_PatientPosisi, M_PatientDivisi, M_PatientLocation, M_PatientDepartement, M_PatientNIP, M_PatientJob, M_PatientPosisi, M_PatientDivisi, M_PatientLocation, M_PatientDepartement,
@@ -169,10 +169,10 @@ BEGIN
ORDER BY mn.Mcu_NumberID DESC ORDER BY mn.Mcu_NumberID DESC
LIMIT 1; LIMIT 1;
DELETE FROM cpone_corporate.mcu_result_all DELETE FROM cpone_dashboard.mcu_result_all
WHERE Mcu_ResultAllT_OrderHeaderID = p_order_header_id; WHERE Mcu_ResultAllT_OrderHeaderID = p_order_header_id;
INSERT INTO cpone_corporate.mcu_result_all ( INSERT INTO cpone_dashboard.mcu_result_all (
Numbering, Mcu_ResultAllM_LangID, Mcu_ResultAllMgm_McuID, Mcu_ResultAllT_OrderHeaderID, Numbering, Mcu_ResultAllM_LangID, Mcu_ResultAllMgm_McuID, Mcu_ResultAllT_OrderHeaderID,
Mcu_ResultAllT_OrderHeaderLabNumber, Mcu_ResultAllReffID, Mcu_ResultAllT_TestCode, Mcu_ResultAllT_OrderHeaderLabNumber, Mcu_ResultAllReffID, Mcu_ResultAllT_TestCode,
Mcu_ResultAllT_TestName, Mcu_ResultAllResult, Mcu_ResultAllUnit, Mcu_ResultAllRefference, Mcu_ResultAllT_TestName, Mcu_ResultAllResult, Mcu_ResultAllUnit, Mcu_ResultAllRefference,

View File

@@ -6,11 +6,14 @@ import (
"encoding/json" "encoding/json"
"html/template" "html/template"
"net/http" "net/http"
"strconv"
) )
var tmpl *template.Template var tmpl *template.Template
var basePath string var basePath string
const defaultPageSize = 30
func SetTemplates(t *template.Template) { tmpl = t } func SetTemplates(t *template.Template) { tmpl = t }
func SetBasePath(p string) { basePath = p } func SetBasePath(p string) { basePath = p }
@@ -28,6 +31,12 @@ type pageData struct {
DepartmentOptions []string DepartmentOptions []string
OverviewJSON template.JS OverviewJSON template.JS
DepartmentJSON template.JS DepartmentJSON template.JS
Page int
PageSize int
HasMore bool
VisibleCount int
TotalFiltered int
NextPage int
} }
func toJS(v interface{}) template.JS { func toJS(v interface{}) template.JS {
@@ -35,33 +44,36 @@ func toJS(v interface{}) template.JS {
return template.JS(b) return template.JS(b)
} }
func Index(w http.ResponseWriter, r *http.Request) { func parsePage(q string) int {
p, err := strconv.Atoi(q)
if err != nil || p < 1 {
return 1
}
return p
}
func buildArrivalData(r *http.Request) (pageData, int, error) {
username := auth.Username(r) username := auth.Username(r)
mcuID := auth.SelectedProjectID(r) mcuID := auth.SelectedProjectID(r)
if mcuID == 0 { if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther) return pageData{}, http.StatusSeeOther, nil
return
} }
project, ok, err := projects.GetUserProject(username, mcuID) project, ok, err := projects.GetUserProject(username, mcuID)
if err != nil { if err != nil {
http.Error(w, "query error", http.StatusInternalServerError) return pageData{}, http.StatusInternalServerError, err
return
} }
if !ok { if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther) return pageData{}, http.StatusSeeOther, nil
return
} }
dates, err := GetArrivalDates(mcuID) dates, err := GetArrivalDates(mcuID)
if err != nil { if err != nil {
http.Error(w, "query error", http.StatusInternalServerError) return pageData{}, http.StatusInternalServerError, err
return
} }
selectedDate := activeDateOrLatest(dates, r.URL.Query().Get("date"), project.StartDate) selectedDate := activeDateOrLatest(dates, r.URL.Query().Get("date"), project.StartDate)
rows, err := GetArrivalRows(mcuID, selectedDate) rows, err := GetArrivalRows(mcuID, selectedDate)
if err != nil { if err != nil {
http.Error(w, "query error", http.StatusInternalServerError) return pageData{}, http.StatusInternalServerError, err
return
} }
summary, deptStats := BuildArrivalStats(rows) summary, deptStats := BuildArrivalStats(rows)
filteredRows := FilterArrivalRows(rows, r.URL.Query().Get("search"), r.URL.Query().Get("dept")) filteredRows := FilterArrivalRows(rows, r.URL.Query().Get("search"), r.URL.Query().Get("dept"))
@@ -69,14 +81,12 @@ func Index(w http.ResponseWriter, r *http.Request) {
stationMap, err := GetStationProgress(mcuID, selectedDate) stationMap, err := GetStationProgress(mcuID, selectedDate)
if err != nil { if err != nil {
http.Error(w, "query error", http.StatusInternalServerError) return pageData{}, http.StatusInternalServerError, err
return
} }
for i := range filteredRows { for i := range filteredRows {
filteredRows[i].Stations = stationMap[filteredRows[i].PreregisterID] 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{} outerDepts := []map[string]any{}
for _, d := range deptStats { for _, d := range deptStats {
if d.Total > 0 { if d.Total > 0 {
@@ -89,7 +99,6 @@ func Index(w http.ResponseWriter, r *http.Request) {
"depts": outerDepts, "depts": outerDepts,
} }
// Chart 2: per-station patient count bar chart
stationStats := BuildStationChart(stationMap) stationStats := BuildStationChart(stationMap)
stationLabels := make([]string, len(stationStats)) stationLabels := make([]string, len(stationStats))
stationCounts := make([]int, len(stationStats)) stationCounts := make([]int, len(stationStats))
@@ -102,12 +111,14 @@ func Index(w http.ResponseWriter, r *http.Request) {
"counts": stationCounts, "counts": stationCounts,
} }
t := tmpl page := parsePage(r.URL.Query().Get("page"))
if t == nil { pagedRows, hasMore := PaginateArrivalRows(filteredRows, page, defaultPageSize)
http.Error(w, "template not ready", http.StatusInternalServerError) visibleCount := page * defaultPageSize
return if visibleCount > len(filteredRows) {
visibleCount = len(filteredRows)
} }
data := pageData{
return pageData{
Username: username, Username: username,
CurrentProject: project, CurrentProject: project,
Date: selectedDate, Date: selectedDate,
@@ -115,14 +126,61 @@ func Index(w http.ResponseWriter, r *http.Request) {
Search: r.URL.Query().Get("search"), Search: r.URL.Query().Get("search"),
Department: r.URL.Query().Get("dept"), Department: r.URL.Query().Get("dept"),
Rows: rows, Rows: rows,
FilteredRows: filteredRows, FilteredRows: pagedRows,
Summary: summary, Summary: summary,
Departments: deptStats, Departments: deptStats,
DepartmentOptions: deptOptions, DepartmentOptions: deptOptions,
OverviewJSON: toJS(overview), OverviewJSON: toJS(overview),
DepartmentJSON: toJS(stationChart), DepartmentJSON: toJS(stationChart),
Page: page,
PageSize: defaultPageSize,
HasMore: hasMore,
VisibleCount: visibleCount,
TotalFiltered: len(filteredRows),
NextPage: page + 1,
}, http.StatusOK, nil
}
func Index(w http.ResponseWriter, r *http.Request) {
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
} }
data, status, err := buildArrivalData(r)
if err != nil {
http.Error(w, "query error", status)
return
}
if status == http.StatusSeeOther {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
if err := t.ExecuteTemplate(w, "base", data); err != nil { if err := t.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError) http.Error(w, "template error", http.StatusInternalServerError)
} }
} }
func List(w http.ResponseWriter, r *http.Request) {
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
data, status, err := buildArrivalData(r)
if err != nil {
http.Error(w, "query error", status)
return
}
if status == http.StatusSeeOther {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if err := t.ExecuteTemplate(w, "arrival-list-chunk", data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}

View File

@@ -270,6 +270,24 @@ func FilterArrivalRows(rows []ArrivalRow, search, dept string) []ArrivalRow {
return out return out
} }
func PaginateArrivalRows(rows []ArrivalRow, page, pageSize int) ([]ArrivalRow, bool) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 30
}
start := (page - 1) * pageSize
if start >= len(rows) {
return []ArrivalRow{}, false
}
end := start + pageSize
if end > len(rows) {
end = len(rows)
}
return rows[start:end], end < len(rows)
}
func UniqueDepartments(rows []ArrivalRow) []string { func UniqueDepartments(rows []ArrivalRow) []string {
seen := map[string]struct{}{} seen := map[string]struct{}{}
var out []string var out []string

View File

@@ -4,4 +4,5 @@ import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) { func Routes(r chi.Router) {
r.Get("/", Index) r.Get("/", Index)
r.Get("/list", List)
} }

View File

@@ -5,11 +5,14 @@ import (
"cpone-dashboard/menu/projects" "cpone-dashboard/menu/projects"
"html/template" "html/template"
"net/http" "net/http"
"strconv"
) )
var tmpl *template.Template var tmpl *template.Template
var basePath string var basePath string
const defaultPageSize = 30
func SetTemplates(t *template.Template) { tmpl = t } func SetTemplates(t *template.Template) { tmpl = t }
func SetBasePath(p string) { basePath = p } func SetBasePath(p string) { basePath = p }
@@ -23,52 +26,108 @@ type pageData struct {
Summary ProgressSummary Summary ProgressSummary
ValidatedPct int ValidatedPct int
PublishedPct int PublishedPct int
Page int
PageSize int
HasMore bool
VisibleCount int
TotalFiltered int
NextPage int
} }
func Index(w http.ResponseWriter, r *http.Request) { func parsePage(q string) int {
p, err := strconv.Atoi(q)
if err != nil || p < 1 {
return 1
}
return p
}
func buildProgressData(r *http.Request) (pageData, int, error) {
username := auth.Username(r) username := auth.Username(r)
mcuID := auth.SelectedProjectID(r) mcuID := auth.SelectedProjectID(r)
if mcuID == 0 { if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther) return pageData{}, http.StatusSeeOther, nil
return
} }
project, ok, err := projects.GetUserProject(username, mcuID) project, ok, err := projects.GetUserProject(username, mcuID)
if err != nil { if err != nil {
http.Error(w, "query error", http.StatusInternalServerError) return pageData{}, http.StatusInternalServerError, err
return
} }
if !ok { if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther) return pageData{}, http.StatusSeeOther, nil
return
} }
rows, err := GetProgressRows(mcuID) rows, err := GetProgressRows(mcuID)
if err != nil { if err != nil {
http.Error(w, "query error", http.StatusInternalServerError) return pageData{}, http.StatusInternalServerError, err
return
} }
summary := BuildProgressSummary(rows) summary := BuildProgressSummary(rows)
search := r.URL.Query().Get("search") search := r.URL.Query().Get("search")
status := r.URL.Query().Get("status") status := r.URL.Query().Get("status")
filteredRows := FilterProgressRows(rows, search, status) filteredRows := FilterProgressRows(rows, search, status)
page := parsePage(r.URL.Query().Get("page"))
t := tmpl pagedRows, hasMore := PaginateProgressRows(filteredRows, page, defaultPageSize)
if t == nil { visibleCount := page * defaultPageSize
http.Error(w, "template not ready", http.StatusInternalServerError) if visibleCount > len(filteredRows) {
return visibleCount = len(filteredRows)
} }
if err := t.ExecuteTemplate(w, "base", pageData{
return pageData{
Username: username, Username: username,
CurrentProject: project, CurrentProject: project,
Search: search, Search: search,
Status: status, Status: status,
Rows: rows, Rows: rows,
FilteredRows: filteredRows, FilteredRows: pagedRows,
Summary: summary, Summary: summary,
ValidatedPct: Pct(summary.Validated, summary.Total), ValidatedPct: Pct(summary.Validated, summary.Total),
PublishedPct: Pct(summary.Published, summary.Total), PublishedPct: Pct(summary.Published, summary.Total),
}); err != nil { Page: page,
PageSize: defaultPageSize,
HasMore: hasMore,
VisibleCount: visibleCount,
TotalFiltered: len(filteredRows),
NextPage: page + 1,
}, http.StatusOK, nil
}
func Index(w http.ResponseWriter, r *http.Request) {
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
data, status, err := buildProgressData(r)
if err != nil {
http.Error(w, "query error", status)
return
}
if status == http.StatusSeeOther {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
if err := t.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func List(w http.ResponseWriter, r *http.Request) {
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
data, status, err := buildProgressData(r)
if err != nil {
http.Error(w, "query error", status)
return
}
if status == http.StatusSeeOther {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if err := t.ExecuteTemplate(w, "progress-list-chunk", data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError) http.Error(w, "template error", http.StatusInternalServerError)
} }
} }

View File

@@ -118,3 +118,21 @@ func Pct(num, total int) int {
} }
return num * 100 / total return num * 100 / total
} }
func PaginateProgressRows(rows []ProgressRow, page, pageSize int) ([]ProgressRow, bool) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 30
}
start := (page - 1) * pageSize
if start >= len(rows) {
return []ProgressRow{}, false
}
end := start + pageSize
if end > len(rows) {
end = len(rows)
}
return rows[start:end], end < len(rows)
}

View File

@@ -4,4 +4,5 @@ import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) { func Routes(r chi.Router) {
r.Get("/", Index) r.Get("/", Index)
r.Get("/list", List)
} }

View File

@@ -5,12 +5,15 @@ import (
"cpone-dashboard/menu/projects" "cpone-dashboard/menu/projects"
"html/template" "html/template"
"net/http" "net/http"
"strconv"
) )
var tmpl *template.Template var tmpl *template.Template
var pdfBaseURL string var pdfBaseURL string
var basePath string var basePath string
const defaultPageSize = 30
func SetTemplates(t *template.Template) { tmpl = t } func SetTemplates(t *template.Template) { tmpl = t }
func SetPDFBaseURL(u string) { pdfBaseURL = u } func SetPDFBaseURL(u string) { pdfBaseURL = u }
func SetBasePath(p string) { basePath = p } func SetBasePath(p string) { basePath = p }
@@ -24,51 +27,108 @@ type pageData struct {
FilteredRows []ResultRow FilteredRows []ResultRow
Summary ResultSummary Summary ResultSummary
PDFBaseURL string PDFBaseURL string
Page int
PageSize int
HasMore bool
VisibleCount int
TotalFiltered int
NextPage int
} }
func Index(w http.ResponseWriter, r *http.Request) { func parsePage(q string) int {
p, err := strconv.Atoi(q)
if err != nil || p < 1 {
return 1
}
return p
}
func buildResultData(r *http.Request) (pageData, int, error) {
username := auth.Username(r) username := auth.Username(r)
mcuID := auth.SelectedProjectID(r) mcuID := auth.SelectedProjectID(r)
if mcuID == 0 { if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther) return pageData{}, http.StatusSeeOther, nil
return
} }
project, ok, err := projects.GetUserProject(username, mcuID) project, ok, err := projects.GetUserProject(username, mcuID)
if err != nil { if err != nil {
http.Error(w, "query error", http.StatusInternalServerError) return pageData{}, http.StatusInternalServerError, err
return
} }
if !ok { if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther) return pageData{}, http.StatusSeeOther, nil
return
} }
rows, err := GetResultRows(mcuID) rows, err := GetResultRows(mcuID)
if err != nil { if err != nil {
http.Error(w, "query error", http.StatusInternalServerError) return pageData{}, http.StatusInternalServerError, err
return
} }
summary := BuildResultSummary(rows) summary := BuildResultSummary(rows)
search := r.URL.Query().Get("search") search := r.URL.Query().Get("search")
filter := r.URL.Query().Get("filter") filter := r.URL.Query().Get("filter")
filteredRows := FilterResultRows(rows, search, filter) filteredRows := FilterResultRows(rows, search, filter)
page := parsePage(r.URL.Query().Get("page"))
t := tmpl pagedRows, hasMore := PaginateResultRows(filteredRows, page, defaultPageSize)
if t == nil { visibleCount := page * defaultPageSize
http.Error(w, "template not ready", http.StatusInternalServerError) if visibleCount > len(filteredRows) {
return visibleCount = len(filteredRows)
} }
if err := t.ExecuteTemplate(w, "base", pageData{
return pageData{
Username: username, Username: username,
CurrentProject: project, CurrentProject: project,
Search: search, Search: search,
Filter: filter, Filter: filter,
Rows: rows, Rows: rows,
FilteredRows: filteredRows, FilteredRows: pagedRows,
Summary: summary, Summary: summary,
PDFBaseURL: pdfBaseURL, PDFBaseURL: pdfBaseURL,
}); err != nil { Page: page,
PageSize: defaultPageSize,
HasMore: hasMore,
VisibleCount: visibleCount,
TotalFiltered: len(filteredRows),
NextPage: page + 1,
}, http.StatusOK, nil
}
func Index(w http.ResponseWriter, r *http.Request) {
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
data, status, err := buildResultData(r)
if err != nil {
http.Error(w, "query error", status)
return
}
if status == http.StatusSeeOther {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
if err := t.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func List(w http.ResponseWriter, r *http.Request) {
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
data, status, err := buildResultData(r)
if err != nil {
http.Error(w, "query error", status)
return
}
if status == http.StatusSeeOther {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if err := t.ExecuteTemplate(w, "result-list-chunk", data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError) http.Error(w, "template error", http.StatusInternalServerError)
} }
} }

View File

@@ -99,3 +99,21 @@ func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow {
} }
return out return out
} }
func PaginateResultRows(rows []ResultRow, page, pageSize int) ([]ResultRow, bool) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 30
}
start := (page - 1) * pageSize
if start >= len(rows) {
return []ResultRow{}, false
}
end := start + pageSize
if end > len(rows) {
end = len(rows)
}
return rows[start:end], end < len(rows)
}

View File

@@ -4,4 +4,5 @@ import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) { func Routes(r chi.Router) {
r.Get("/", Index) r.Get("/", Index)
r.Get("/list", List)
} }

View File

@@ -39,19 +39,19 @@
</section> </section>
<section class="grid gap-4 sm:grid-cols-3"> <section class="grid gap-4 sm:grid-cols-3">
<article class="card p-5"> <article class="card border-l-4 border-l-brand-300 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked In</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked In</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.CheckedIn}}</p> <p class="num mt-3 text-3xl font-semibold text-slate-900">{{.Summary.CheckedIn}}</p>
<p class="mt-1 text-xs text-slate-400">Sudah check-in pada tanggal ini</p> <p class="mt-1 text-xs text-slate-400">Sudah check-in pada tanggal ini</p>
</article> </article>
<article class="card p-5"> <article class="card border-l-4 border-l-brand-500 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Not Check-in Yet</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Not Check-in Yet</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Pending}}</p> <p class="num mt-3 text-3xl font-semibold text-slate-900">{{.Summary.Pending}}</p>
<p class="mt-1 text-xs text-slate-400">Belum masuk ke area MCU</p> <p class="mt-1 text-xs text-slate-400">Belum masuk ke area MCU</p>
</article> </article>
<article class="card p-5"> <article class="card border-l-4 border-l-emerald-500 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Schedule</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Schedule</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p> <p class="num mt-3 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta yang dijadwalkan hari ini</p> <p class="mt-1 text-xs text-slate-400">Peserta yang dijadwalkan hari ini</p>
</article> </article>
</section> </section>
@@ -62,7 +62,8 @@
<p class="text-sm font-semibold text-slate-700">Check-in Overview</p> <p class="text-sm font-semibold text-slate-700">Check-in Overview</p>
<p class="text-xs text-slate-400">Inner ring: checked-in summary, outer ring: distribution by department / posisi</p> <p class="text-xs text-slate-400">Inner ring: checked-in summary, outer ring: distribution by department / posisi</p>
</div> </div>
<div id="arrival-overview-chart" class="h-72 w-full"></div> <div id="arrival-overview-chart" class="h-72 w-full sm:h-80"></div>
<div id="arrival-overview-legend-mobile" class="mt-3 hidden grid-cols-2 gap-x-4 gap-y-2 text-xs text-slate-600 sm:hidden"></div>
</article> </article>
<article class="card p-5"> <article class="card p-5">
<div class="mb-3"> <div class="mb-3">
@@ -105,7 +106,7 @@
<h2 class="text-base font-semibold text-slate-700">Live Arrival List</h2> <h2 class="text-base font-semibold text-slate-700">Live Arrival List</h2>
<p class="text-xs text-slate-400">Tanggal: {{.Date | fmtDate}}</p> <p class="text-xs text-slate-400">Tanggal: {{.Date | fmtDate}}</p>
</div> </div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span> <span class="text-xs font-medium text-slate-400">{{.VisibleCount}} / {{.TotalFiltered}} ditampilkan</span>
</div> </div>
<div class="border-b border-slate-100 px-5 py-3"> <div class="border-b border-slate-100 px-5 py-3">
@@ -128,7 +129,7 @@
<th class="px-4 py-3 font-medium">Status</th> <th class="px-4 py-3 font-medium">Status</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody id="arrival-desktop-rows" class="divide-y divide-slate-100">
{{range .FilteredRows}} {{range .FilteredRows}}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3 num">{{if .InTime}}{{.InTime}}{{else}}-{{end}}</td> <td class="px-4 py-3 num">{{if .InTime}}{{.InTime}}{{else}}-{{end}}</td>
@@ -166,44 +167,91 @@
</table> </table>
</div> </div>
<div class="grid gap-3 p-4 md:hidden"> <div id="arrival-mobile-rows" class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}} {{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3"> <article class="rounded-xl border border-slate-200 p-3">
<p class="font-semibold text-slate-700">{{.Name}}</p> <p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p> <p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p>
<div class="mt-2 flex flex-wrap gap-1.5"> <div class="mt-3 flex flex-col gap-1.5">
{{if .Stations}} {{if .Stations}}
{{range .Stations}} {{range .Stations}}
{{if eq .Tone "success"}} {{if eq .Tone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span> <span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span class="truncate">{{.Name}}</span>
</span>
{{else if eq .Tone "warning"}} {{else if eq .Tone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span> <span class="flex w-full items-center gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Name}}</span>
</span>
{{else if eq .Tone "danger"}} {{else if eq .Tone "danger"}}
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span> <span class="flex w-full items-center gap-2 border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-medium text-rose-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Name}}</span>
</span>
{{else}} {{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span> <span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Name}}</span>
</span>
{{end}} {{end}}
{{end}} {{end}}
{{else}} {{else}}
{{if eq .StatusTone "success"}} {{if eq .StatusTone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span> <span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span class="truncate">{{.Status}}</span>
</span>
{{else if eq .StatusTone "warning"}} {{else if eq .StatusTone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span> <span class="flex w-full items-center gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Status}}</span>
</span>
{{else}} {{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span> <span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="truncate">{{.Status}}</span>
</span>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
</article> </article>
{{end}} {{end}}
</div> </div>
<div id="arrival-load-more" class="px-4 pb-4">
{{if .HasMore}}
<div
hx-get='{{b "/arrival/list"}}?date={{.Date}}&search={{.Search}}&dept={{.Department}}&page={{.NextPage}}'
hx-trigger="revealed"
hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
Memuat data berikutnya...
</div>
{{else}}
<div class="text-center text-xs text-slate-400">Semua data sudah ditampilkan</div>
{{end}}
</div>
{{else}} {{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400"> <div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data arrival pada tanggal ini. Belum ada data arrival pada tanggal ini.
</div> </div>
{{end}} {{end}}
</section> </section>
<script> <script>
(function() { (function() {
const arrivalFilterForm = document.getElementById('arrival-filter-form'); const arrivalFilterForm = document.getElementById('arrival-filter-form');
@@ -229,20 +277,41 @@
const deptColors = ['#f59e0b', '#8b5cf6', '#f97316', '#06b6d4', '#ec4899', '#84cc16', '#14b8a6']; const deptColors = ['#f59e0b', '#8b5cf6', '#f97316', '#06b6d4', '#ec4899', '#84cc16', '#14b8a6'];
const overviewEl = document.getElementById('arrival-overview-chart'); const overviewEl = document.getElementById('arrival-overview-chart');
const overviewLegendMobileEl = document.getElementById('arrival-overview-legend-mobile');
if (overviewEl && overviewData && typeof echarts !== 'undefined') { if (overviewEl && overviewData && typeof echarts !== 'undefined') {
const overviewChart = echarts.init(overviewEl); const overviewChart = echarts.init(overviewEl);
const outerData = (overviewData.depts || []).map(function(d) { const outerData = (overviewData.depts || []).map(function(d) {
return { value: d.value, name: d.name }; return { value: d.value, name: d.name };
}); });
const isMobile = window.matchMedia('(max-width: 639px)').matches;
if (isMobile && overviewLegendMobileEl) {
const legendItems = [
{ name: 'Checked In', color: '#3b50a0' },
{ name: 'Not Check-in Yet', color: '#cbd5e1' }
].concat((overviewData.depts || []).map(function(d, idx) {
return { name: d.name, color: deptColors[idx % deptColors.length] };
}));
overviewLegendMobileEl.innerHTML = legendItems.map(function(item) {
return '<div class="flex items-center gap-2 min-w-0">' +
'<span class="h-2.5 w-2.5 shrink-0 rounded-sm" style="background:' + item.color + '"></span>' +
'<span class="truncate">' + item.name + '</span>' +
'</div>';
}).join('');
overviewLegendMobileEl.classList.remove('hidden');
overviewLegendMobileEl.classList.add('grid');
}
overviewChart.setOption({ overviewChart.setOption({
color: ['#3b50a0', '#cbd5e1'].concat(deptColors), color: ['#3b50a0', '#cbd5e1'].concat(deptColors),
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { legend: {
show: !isMobile,
type: 'scroll', type: 'scroll',
orient: 'vertical', orient: 'vertical',
left: '58%',
right: 0, right: 0,
top: 'middle', top: 'middle',
textStyle: { color: '#64748b', fontSize: 11 }, textStyle: { color: '#64748b', fontSize: 11 },
itemGap: 8,
selectedMode: false, selectedMode: false,
pageIconColor: '#3b50a0', pageIconColor: '#3b50a0',
pageTextStyle: { color: '#64748b' } pageTextStyle: { color: '#64748b' }
@@ -252,7 +321,7 @@
name: 'Check-in Summary', name: 'Check-in Summary',
type: 'pie', type: 'pie',
radius: ['28%', '45%'], radius: ['28%', '45%'],
center: ['38%', '48%'], center: isMobile ? ['50%', '50%'] : ['38%', '48%'],
label: { show: false }, label: { show: false },
labelLine: { show: false }, labelLine: { show: false },
data: [ data: [
@@ -264,7 +333,7 @@
name: 'Dept Detail', name: 'Dept Detail',
type: 'pie', type: 'pie',
radius: ['55%', '72%'], radius: ['55%', '72%'],
center: ['38%', '48%'], center: isMobile ? ['50%', '50%'] : ['38%', '48%'],
label: { show: false }, label: { show: false },
labelLine: { show: false }, labelLine: { show: false },
data: outerData data: outerData
@@ -304,3 +373,108 @@
})(); })();
</script> </script>
{{end}} {{end}}
{{define "arrival-list-chunk"}}
<tbody id="arrival-desktop-rows" hx-swap-oob="beforeend">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 num">{{if .InTime}}{{.InTime}}{{else}}-{{end}}</td>
<td class="px-4 py-3 num">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Department}}</td>
<td class="px-4 py-3">
{{if .Stations}}
<div class="flex flex-wrap gap-1.5">
{{range .Stations}}
{{if eq .Tone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
{{else if eq .Tone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "danger"}}
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
{{end}}
{{end}}
</div>
{{else}}
{{if eq .StatusTone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
{{else if eq .StatusTone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
{{end}}
{{end}}
</td>
</tr>
{{end}}
</tbody>
<div id="arrival-mobile-rows" hx-swap-oob="beforeend">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p>
<div class="mt-3 flex flex-col gap-1.5">
{{if .Stations}}
{{range .Stations}}
{{if eq .Tone "success"}}
<span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<span class="truncate">{{.Name}}</span>
</span>
{{else if eq .Tone "warning"}}
<span class="flex w-full items-center gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Name}}</span>
</span>
{{else if eq .Tone "danger"}}
<span class="flex w-full items-center gap-2 border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-medium text-rose-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Name}}</span>
</span>
{{else}}
<span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Name}}</span>
</span>
{{end}}
{{end}}
{{else}}
{{if eq .StatusTone "success"}}
<span class="flex w-full items-center gap-2 border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<span class="truncate">{{.Status}}</span>
</span>
{{else if eq .StatusTone "warning"}}
<span class="flex w-full items-center gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Status}}</span>
</span>
{{else}}
<span class="flex w-full items-center gap-2 border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-600">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
<span class="truncate">{{.Status}}</span>
</span>
{{end}}
{{end}}
</div>
</article>
{{end}}
</div>
<div id="arrival-load-more" hx-swap-oob="outerHTML">
{{if .HasMore}}
<div
hx-get='{{b "/arrival/list"}}?date={{.Date}}&search={{.Search}}&dept={{.Department}}&page={{.NextPage}}'
hx-trigger="revealed"
hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
Memuat data berikutnya...
</div>
{{else}}
<div class="text-center text-xs text-slate-400">Semua data sudah ditampilkan</div>
{{end}}
</div>
{{end}}

View File

@@ -207,22 +207,16 @@
Lihat Lihat
</button> </button>
</form> </form>
{{if gt .KPI.InvitedStaff 0}}
<div class="rounded-xl bg-brand-50 px-3 py-2 text-center">
<p class="text-xs text-slate-500">Invited Staff</p>
<p class="num text-sm font-semibold text-brand-600">{{.KPI.InvitedStaff}}</p>
</div>
{{end}}
</div> </div>
</div> </div>
</section> </section>
<!-- KPI Cards — SSE swap --> <!-- KPI Cards — SSE swap -->
<section id="sse-kpi" class="grid gap-4 sm:grid-cols-3 sse-anim-target" <section id="sse-kpi" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 sse-anim-target"
sse-swap="kpi" hx-swap="innerHTML"> sse-swap="kpi" hx-swap="innerHTML">
<span class="sse-running-bar" aria-hidden="true"></span> <span class="sse-running-bar" aria-hidden="true"></span>
<span class="sse-updating-label" aria-hidden="true">Updating...</span> <span class="sse-updating-label" aria-hidden="true">Updating...</span>
{{range $i := seq 3}} {{range $i := seq 4}}
<div class="card h-28 animate-pulse bg-slate-50"></div> <div class="card h-28 animate-pulse bg-slate-50"></div>
{{end}} {{end}}
</section> </section>

View File

@@ -1,4 +1,16 @@
{{define "kpi"}} {{define "kpi"}}
<!-- Invited Staff -->
<article class="card border-l-4 border-l-brand-200 p-4">
<div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Invited Staff</p>
<svg class="h-4 w-4 text-brand-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.121 17.804A9.956 9.956 0 0112 15c2.2 0 4.236.711 5.879 1.915M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.InvitedStaff}}</p>
<p class="mt-1 text-xs text-slate-400">Total staff undangan</p>
</article>
<!-- Total Staff --> <!-- Total Staff -->
<article class="card border-l-4 border-l-brand-300 p-4"> <article class="card border-l-4 border-l-brand-300 p-4">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">

View File

@@ -8,15 +8,15 @@
{{end}} {{end}}
</div> </div>
{{if .Rows}} {{if .Rows}}
<div class="p-3"> <div class="overflow-x-auto p-3">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm md:table-fixed">
<thead> <thead>
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-400"> <tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-400">
<th class="px-3 py-2">Station</th> <th class="px-3 py-2">Station</th>
<th class="px-3 py-2 text-right">Sudah</th> <th class="hidden px-3 py-2 text-right sm:table-cell">Sudah</th>
<th class="px-3 py-2 text-right">Belum</th> <th class="px-3 py-2 text-right">Belum</th>
<th class="px-3 py-2 text-right">Total</th> <th class="px-3 py-2 text-right">Total</th>
<th class="px-3 py-2 w-40">Progress</th> <th class="px-3 py-2 sm:w-40">Progress</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-50"> <tbody class="divide-y divide-slate-50">
@@ -29,7 +29,7 @@
<td class="px-3 py-2.5 font-medium text-slate-700"> <td class="px-3 py-2.5 font-medium text-slate-700">
{{.Station | stationShort}} {{.Station | stationShort}}
</td> </td>
<td class="num px-3 py-2.5 text-right font-semibold text-slate-900">{{.Processed}}</td> <td class="num hidden px-3 py-2.5 text-right font-semibold text-slate-900 sm:table-cell">{{.Processed}}</td>
<td class="num px-3 py-2.5 text-right text-amber-600">{{.Pending}}</td> <td class="num px-3 py-2.5 text-right text-amber-600">{{.Pending}}</td>
<td class="num px-3 py-2.5 text-right text-slate-400">{{.Total}}</td> <td class="num px-3 py-2.5 text-right text-slate-400">{{.Total}}</td>
<td class="px-3 py-2.5"> <td class="px-3 py-2.5">

View File

@@ -53,16 +53,27 @@
<body class="min-h-screen bg-slate-100 text-slate-800"> <body class="min-h-screen bg-slate-100 text-slate-800">
<!-- Header --> <!-- Header -->
<header class="bg-brand-500 text-white"> <header class="w-full bg-brand-500 text-white">
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8"> <div class="mx-auto flex w-full items-center justify-between px-4 py-3 sm:px-6 lg:max-w-7xl lg:px-8">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="{{b "/dashboard"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5"> <a href="{{b "/dashboard"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto"> <img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</a> </a>
<div> <div class="min-w-0">
<p class="text-sm font-semibold leading-tight">{{block "header-title" .}}Dashboard{{end}}</p> <p class="text-sm font-semibold leading-tight">{{block "header-title" .}}Dashboard{{end}}</p>
</div> </div>
</div> </div>
<button
id="mobile-menu-toggle"
type="button"
class="inline-flex items-center justify-center rounded-lg p-2 text-white transition hover:bg-white/15 sm:hidden"
aria-label="Buka menu navigasi"
aria-controls="mobile-menu"
aria-expanded="false">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<div class="hidden items-center gap-1 text-sm sm:flex"> <div class="hidden items-center gap-1 text-sm sm:flex">
<nav class="flex items-center gap-1"> <nav class="flex items-center gap-1">
<a href="{{b "/dashboard"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Dashboard</a> <a href="{{b "/dashboard"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Dashboard</a>
@@ -79,12 +90,39 @@
{{end}} {{end}}
</div> </div>
</div> </div>
<div id="mobile-menu" class="hidden border-t border-white/20 px-4 py-3 sm:hidden">
<nav class="flex flex-col gap-1 text-sm">
<a href="{{b "/dashboard"}}" class="rounded-lg px-3 py-2 font-medium transition hover:bg-white/15">Dashboard</a>
<a href="{{b "/arrival"}}" class="rounded-lg px-3 py-2 font-medium transition hover:bg-white/15">Arrival</a>
<a href="{{b "/progress"}}" class="rounded-lg px-3 py-2 font-medium transition hover:bg-white/15">Progress</a>
<a href="{{b "/abnormal"}}" class="rounded-lg px-3 py-2 font-medium transition hover:bg-white/15">Abnormal</a>
<a href="{{b "/result"}}" class="rounded-lg px-3 py-2 font-medium transition hover:bg-white/15">Result</a>
</nav>
{{if .Username}}
<div class="mt-3 flex items-center justify-between gap-2 border-t border-white/20 pt-3">
<a href="{{b "/password"}}" class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide transition hover:bg-white/25">{{.Username}}</a>
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 text-sm font-medium opacity-85 transition hover:bg-white/15 hover:opacity-100">Logout</a>
</div>
{{end}}
</div>
</header> </header>
<main class="mx-auto w-full max-w-7xl space-y-5 px-4 py-5 sm:px-6 lg:px-8"> <main class="mx-auto w-full max-w-7xl space-y-5 px-4 py-5 sm:px-6 lg:px-8">
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</main> </main>
<script>
(() => {
const btn = document.getElementById('mobile-menu-toggle');
const menu = document.getElementById('mobile-menu');
if (!btn || !menu) return;
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
menu.classList.toggle('hidden');
});
})();
</script>
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@@ -23,19 +23,19 @@
</section> </section>
<section class="grid gap-4 sm:grid-cols-3"> <section class="grid gap-4 sm:grid-cols-3">
<article class="card p-5"> <article class="card border-l-4 border-l-brand-300 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p> <p class="num mt-3 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p> <p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
</article> </article>
<article class="card p-5"> <article class="card border-l-4 border-l-emerald-500 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Validated</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Validated</p>
<p class="num mt-2 text-3xl font-semibold text-emerald-600">{{.Summary.Validated}}</p> <p class="num mt-3 text-3xl font-semibold text-emerald-600">{{.Summary.Validated}}</p>
<p class="mt-1 text-xs text-slate-400">Resume sudah divalidasi dokter</p> <p class="mt-1 text-xs text-slate-400">Resume sudah divalidasi dokter</p>
</article> </article>
<article class="card p-5"> <article class="card border-l-4 border-l-brand-500 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Published</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Published</p>
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.Published}}</p> <p class="num mt-3 text-3xl font-semibold text-brand-500">{{.Summary.Published}}</p>
<p class="mt-1 text-xs text-slate-400">Hasil sudah diupload</p> <p class="mt-1 text-xs text-slate-400">Hasil sudah diupload</p>
</article> </article>
</section> </section>
@@ -102,7 +102,7 @@
<h2 class="text-base font-semibold text-slate-700">Patient Resume List</h2> <h2 class="text-base font-semibold text-slate-700">Patient Resume List</h2>
<p class="text-xs text-slate-400">Data dari mcu_patient_resume_status</p> <p class="text-xs text-slate-400">Data dari mcu_patient_resume_status</p>
</div> </div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span> <span class="text-xs font-medium text-slate-400">{{.VisibleCount}} / {{.TotalFiltered}} ditampilkan</span>
</div> </div>
<div class="border-b border-slate-100 px-5 py-3"> <div class="border-b border-slate-100 px-5 py-3">
@@ -127,7 +127,7 @@
<th class="px-4 py-3 font-medium">Published</th> <th class="px-4 py-3 font-medium">Published</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody id="progress-desktop-rows" class="divide-y divide-slate-100">
{{range .FilteredRows}} {{range .FilteredRows}}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td> <td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
@@ -160,7 +160,7 @@
</table> </table>
</div> </div>
<div class="grid gap-3 p-4 md:hidden"> <div id="progress-mobile-rows" class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}} {{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3"> <article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
@@ -187,6 +187,19 @@
</article> </article>
{{end}} {{end}}
</div> </div>
<div id="progress-load-more" class="px-4 pb-4">
{{if .HasMore}}
<div
hx-get='{{b "/progress/list"}}?search={{.Search}}&status={{.Status}}&page={{.NextPage}}'
hx-trigger="revealed"
hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
Memuat data berikutnya...
</div>
{{else}}
<div class="text-center text-xs text-slate-400">Semua data sudah ditampilkan</div>
{{end}}
</div>
{{else}} {{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400"> <div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data resume untuk project ini. Belum ada data resume untuk project ini.
@@ -215,3 +228,78 @@
})(); })();
</script> </script>
{{end}} {{end}}
{{define "progress-list-chunk"}}
<tbody id="progress-desktop-rows" hx-swap-oob="beforeend">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 num text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="px-4 py-3">
{{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
{{else}}
<span class="text-xs text-slate-400"></span>
{{end}}
</td>
<td class="px-4 py-3">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Y</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
{{end}}
</td>
<td class="px-4 py-3">
{{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Y</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
<div id="progress-mobile-rows" hx-swap-oob="beforeend">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
</div>
{{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
{{end}}
</div>
<div class="mt-2 flex gap-2">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Validated</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Validated</span>
{{end}}
{{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Published</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Published</span>
{{end}}
</div>
</article>
{{end}}
</div>
<div id="progress-load-more" hx-swap-oob="outerHTML">
{{if .HasMore}}
<div
hx-get='{{b "/progress/list"}}?search={{.Search}}&status={{.Status}}&page={{.NextPage}}'
hx-trigger="revealed"
hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
Memuat data berikutnya...
</div>
{{else}}
<div class="text-center text-xs text-slate-400">Semua data sudah ditampilkan</div>
{{end}}
</div>
{{end}}

View File

@@ -25,14 +25,14 @@
{{/* Section 2: Summary cards */}} {{/* Section 2: Summary cards */}}
<section class="grid gap-4 sm:grid-cols-2"> <section class="grid gap-4 sm:grid-cols-2">
<article class="card p-5"> <article class="card border-l-4 border-l-brand-300 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p> <p class="num mt-3 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p> <p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
</article> </article>
<article class="card p-5"> <article class="card border-l-4 border-l-brand-500 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Has PDF</p> <p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Sudah Ada Report Hasil</p>
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.HasPDF}}</p> <p class="num mt-3 text-3xl font-semibold text-brand-500">{{.Summary.HasPDF}}</p>
<p class="mt-1 text-xs text-slate-400">Laporan hasil sudah tersedia</p> <p class="mt-1 text-xs text-slate-400">Laporan hasil sudah tersedia</p>
</article> </article>
</section> </section>
@@ -69,7 +69,7 @@
<h2 class="text-base font-semibold text-slate-700">Patient Result List</h2> <h2 class="text-base font-semibold text-slate-700">Patient Result List</h2>
<p class="text-xs text-slate-400">Data dari published_mcu_dashboard_sync</p> <p class="text-xs text-slate-400">Data dari published_mcu_dashboard_sync</p>
</div> </div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span> <span class="text-xs font-medium text-slate-400">{{.VisibleCount}} / {{.TotalFiltered}} ditampilkan</span>
</div> </div>
{{if .FilteredRows}} {{if .FilteredRows}}
@@ -84,7 +84,7 @@
<th class="px-4 py-3 font-medium">Action</th> <th class="px-4 py-3 font-medium">Action</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody id="result-desktop-rows" class="divide-y divide-slate-100">
{{range .FilteredRows}} {{range .FilteredRows}}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td> <td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
@@ -109,7 +109,7 @@
</table> </table>
</div> </div>
<div class="grid gap-3 p-4 md:hidden"> <div id="result-mobile-rows" class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}} {{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3"> <article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
@@ -130,6 +130,19 @@
</article> </article>
{{end}} {{end}}
</div> </div>
<div id="result-load-more" class="px-4 pb-4">
{{if .HasMore}}
<div
hx-get='{{b "/result/list"}}?search={{.Search}}&filter={{.Filter}}&page={{.NextPage}}'
hx-trigger="revealed"
hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
Memuat data berikutnya...
</div>
{{else}}
<div class="text-center text-xs text-slate-400">Semua data sudah ditampilkan</div>
{{end}}
</div>
{{else}} {{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400"> <div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data untuk project ini. Belum ada data untuk project ini.
@@ -196,3 +209,64 @@ document.addEventListener('keydown', function(e) {
}); });
</script> </script>
{{end}} {{end}}
{{define "result-list-chunk"}}
<tbody id="result-desktop-rows" hx-swap-oob="beforeend">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="num px-4 py-3 text-slate-500">
{{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300"></span>{{end}}
</td>
<td class="px-4 py-3">
{{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')"
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF
</button>
{{else}}
<span class="text-xs text-slate-300"></span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
<div id="result-mobile-rows" hx-swap-oob="beforeend">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
</div>
{{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')"
class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF
</button>
{{else}}
<span class="text-xs text-slate-300">No PDF</span>
{{end}}
</div>
</article>
{{end}}
</div>
<div id="result-load-more" hx-swap-oob="outerHTML">
{{if .HasMore}}
<div
hx-get='{{b "/result/list"}}?search={{.Search}}&filter={{.Filter}}&page={{.NextPage}}'
hx-trigger="revealed"
hx-swap="outerHTML"
class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-center text-xs font-medium text-slate-500">
Memuat data berikutnya...
</div>
{{else}}
<div class="text-center text-xs text-slate-400">Semua data sudah ditampilkan</div>
{{end}}
</div>
{{end}}