16 KiB
Result Menu Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implementasi halaman /result yang menampilkan daftar peserta MCU beserta tombol View PDF, mengambil data dari cpone_dashboard.mcu_patient dan cpone_dashboard.published_mcu_dashboard_sync.
Architecture: Handler mengikuti pola progress — fetch semua rows, build summary, apply filter di memory, render template. PDF base URL dikonfigurasi via .env sebagai PDF_BASE_URL, disimpan di package-level var pdfBaseURL di package result.
Tech Stack: Go 1.21, Chi router, Go HTML templates (embed), Tailwind via CDN, MySQL 8 (cpone_dashboard).
Working directory untuk semua command: /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
File Map
| File | Status | Tanggung jawab |
|---|---|---|
config/config.go |
Modify | Tambah field PDFBaseURL string |
.env |
Modify | Tambah PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/ |
.env.example |
Modify | Tambah PDF_BASE_URL=http://your-server/dashboard-files/ |
menu/result/query.go |
Rewrite | Types + query + filter/summary helpers |
menu/result/handler.go |
Rewrite | pageData, pdfBaseURL var, Index handler |
main.go |
Modify | Wire cfg.PDFBaseURL ke result.SetPDFBaseURL |
templates/result/index.html |
Rewrite | Full page template |
Task 1: Tambah PDFBaseURL ke config
Files:
-
Modify:
config/config.go -
Modify:
.env -
Modify:
.env.example -
Step 1: Update config/config.go
Tambah field PDFBaseURL ke struct dan Load():
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
AppPort string
DBDSN string
AuthSecret string
PDFBaseURL string
}
func Load() *Config {
if err := godotenv.Load(); err != nil {
log.Println("no .env file, reading from environment")
}
return &Config{
AppPort: getEnv("APP_PORT", "8080"),
DBDSN: getEnv("DB_DSN", ""),
AuthSecret: getEnv("AUTH_SECRET", "cpone-change-this-secret"),
PDFBaseURL: getEnv("PDF_BASE_URL", ""),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
- Step 2: Tambah ke .env
Buka .env, tambah baris di bawah AUTH_SECRET:
PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/
- Step 3: Tambah ke .env.example
Buka .env.example, tambah baris di bawah AUTH_SECRET:
PDF_BASE_URL=http://your-server/dashboard-files/
- Step 4: Verifikasi build
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
Expected: tidak ada error output.
- Step 5: Commit
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add config/config.go .env .env.example
git commit -m "config: add PDF_BASE_URL env var"
Task 2: Implementasi query.go
Files:
-
Rewrite:
menu/result/query.go -
Step 1: Tulis query.go lengkap
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
}
- Step 2: Verifikasi build
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
Expected: tidak ada error.
- Step 3: Commit
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add menu/result/query.go
git commit -m "result: implement query, summary, and filter helpers"
Task 3: Implementasi handler.go
Files:
-
Rewrite:
menu/result/handler.go -
Step 1: Tulis handler.go lengkap
package result
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"html/template"
"net/http"
)
var tmpl *template.Template
var pdfBaseURL string
func SetTemplates(t *template.Template) { tmpl = t }
func SetPDFBaseURL(u string) { pdfBaseURL = u }
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, "/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, "/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)
}
}
- Step 2: Verifikasi build
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
Expected: tidak ada error.
- Step 3: Commit
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add menu/result/handler.go
git commit -m "result: implement Index handler with filter and summary"
Task 4: Wire PDFBaseURL di main.go
Files:
-
Modify:
main.go— tambahresult.SetPDFBaseURL(cfg.PDFBaseURL)setelahresult.SetTemplates(...) -
Step 1: Temukan baris result.SetTemplates di main.go
Cari baris (sekitar line 254):
result.SetTemplates(newPageTmpl("templates/result/index.html"))
- Step 2: Tambah SetPDFBaseURL tepat setelahnya
result.SetTemplates(newPageTmpl("templates/result/index.html"))
result.SetPDFBaseURL(cfg.PDFBaseURL)
- Step 3: Verifikasi build
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
Expected: tidak ada error.
- Step 4: Commit
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add main.go
git commit -m "main: wire PDF_BASE_URL into result handler"
Task 5: Implementasi template
Files:
-
Rewrite:
templates/result/index.html -
Step 1: Tulis template lengkap
{{define "title"}}Result Reports — CpOne{{end}}
{{define "header-title"}}Consolidated Result Reports{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
{{/* Section 1: Current project */}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} • {{$proj.CorporateName}} •
<span class="num">{{$proj.StartDate | fmtDate}}</span> – <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<a href="/projects" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project
</a>
</div>
</section>
{{/* Section 2: Summary cards */}}
<section class="grid gap-4 sm:grid-cols-2">
<article class="card p-5">
<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="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Has PDF</p>
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.HasPDF}}</p>
<p class="mt-1 text-xs text-slate-400">Laporan hasil sudah tersedia</p>
</article>
</section>
{{/* Section 3: Filter form */}}
<section class="card p-4">
<form method="get" action="/result" class="grid gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div>
<label for="filter" class="mb-2 block text-sm font-medium text-slate-600">Status PDF</label>
<select id="filter" name="filter"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Filter ""}}selected{{end}}>All</option>
<option value="has_pdf" {{if eq .Filter "has_pdf"}}selected{{end}}>Has PDF</option>
<option value="no_pdf" {{if eq .Filter "no_pdf"}}selected{{end}}>No PDF</option>
</select>
</div>
<div class="md:col-span-3 flex justify-end">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</div>
</form>
</section>
{{/* Section 4: Patient list */}}
<section class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
<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>
</div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
</div>
{{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Employee ID</th>
<th class="px-4 py-3 font-medium">Patient</th>
<th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Report Date</th>
<th class="px-4 py-3 font-medium">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{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}}
<a href="{{$.PDFBaseURL}}{{.FileUrl}}" target="_blank"
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
</a>
{{else}}
<span class="text-xs text-slate-300">—</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="grid gap-3 p-4 md:hidden">
{{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}} • {{.Posisi}}</p>
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
</div>
{{if .FileUrl}}
<a href="{{$.PDFBaseURL}}{{.FileUrl}}" target="_blank"
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
</a>
{{else}}
<span class="text-xs text-slate-300">No PDF</span>
{{end}}
</div>
</article>
{{end}}
</div>
{{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data untuk project ini.
</div>
{{end}}
</section>
{{end}}
- Step 2: Build dan cek tidak ada syntax error template
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
Expected: tidak ada error.
- Step 3: Commit
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add templates/result/index.html
git commit -m "result: implement full page template"
Task 6: Manual verification
- Step 1: Pastikan SSH tunnel aktif, lalu jalankan app
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
make start
Expected: server running on :8080
- Step 2: Buka browser, login, pilih project
Navigasi ke http://localhost:8080/result. Harus tampil:
-
Header "Consolidated Result Reports"
-
Summary cards: Total Patients dan Has PDF (sesuai data di DB)
-
Table daftar pasien
-
Step 3: Verifikasi tombol View PDF
Row pertama (NIP sesuai data) harus ada tombol "View PDF" berwarna brand-500. Klik — harus buka PDF di tab baru dengan URL http://devcpone.aplikasi.web.id/dashboard-files/2024/09/R2409170003_resume_individu.pdf.
- Step 4: Verifikasi filter
Pilih filter "Has PDF" → hanya 1 row tampil. Pilih "No PDF" → semua row selain 1 tampil. Ketik nama di search → filter berjalan.
- Step 5: Deploy ke server
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
make deploy
Expected: deployed to one@devcpone.aplikasi.web.id:/home/one/project/cpone-dashboard