Files
cpone_dashboard/docs/superpowers/plans/2026-04-30-result-menu.md
2026-04-30 14:27:01 +07:00

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 — tambah result.SetPDFBaseURL(cfg.PDFBaseURL) setelah result.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}} &bull; {{$proj.CorporateName}} &bull;
        <span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <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}} &bull; {{.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