From b46096473b02b98e4eedbcb223f49a5fcb2195ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nut=2E=E0=B9=84=E0=B8=9B=E0=B9=80=E0=B8=A3=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=A2?= Date: Wed, 22 Jul 2026 18:33:05 +0700 Subject: [PATCH] docs: add implementation plan for expense-project-research edit-history view Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01613NaY9LAvy2wFXEeyE5eR --- ...-07-22-expense-project-research-history.md | 598 ++++++++++++++++++ 1 file changed, 598 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-expense-project-research-history.md diff --git a/docs/superpowers/plans/2026-07-22-expense-project-research-history.md b/docs/superpowers/plans/2026-07-22-expense-project-research-history.md new file mode 100644 index 0000000..66595c8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-expense-project-research-history.md @@ -0,0 +1,598 @@ +# Expense-Project-Research Edit-History View 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:** Add a "ดูประวัติการแก้ไข" (view edit history) affordance — icon, edit-count badge, expandable session table — to the `expense-project-research` list (`List14ResearchComponent`), showing project detail-field diffs (ชื่อโครงการ, แผนงาน, ผลผลิต, ประเด็นยุทธศาสตร์, ลักษณะโครงการ, หน่วยงานที่รับผิดชอบ, แหล่งที่มาของเงิน) with the pre-edit original always pinned last. + +**Architecture:** No backend changes — `GET /request_budget/change_project_research_detail/{uid}/history` already returns field-level diff rows including a synthetic "ต้นฉบับ" (original) snapshot. Two pure functions (`buildHistorySessions`, `countEditSessions`) that already implement the "group into sessions, original last" logic are extracted out of the large shared `make-year-plant-form.component.ts` into a new util module, then reused by `List14ResearchComponent`, which gets its own history-fetch/expand-row state (independent of the shared form component — different page, different data shape, no P/A metrics table). The visible history table shows a different field set than the existing (typeUrl 12) implementation: one row per edit session with the 7 project-detail columns instead of per-P/A-row numeric columns. + +**Tech Stack:** Angular 17, Angular Material (`mat-icon`, `mat-icon-button`, `mat-spinner`), RxJS (`forkJoin`, `catchError`). + +## Global Constraints + +- No `rmutr-api` changes — the history endpoint and diff data already exist and cover every field this plan displays. +- This repo has no unit-test culture for these Angular components. Per-task verification uses `ng build --configuration=production` (from `/Users/nut.looknut/Project/rmutr/rmutr-web`) to catch compile/template errors; the final task is a manual browser check via `npm start`. +- Extracting `buildHistorySessions`/`countEditSessions` out of `make-year-plant-form.component.ts` must not change behavior for the existing `typeUrl 12` (`change-project-research-form/list-all`) history view — same output for the same input, verified by a regression check in the final task. +- `List14ResearchComponent` uses `ChangeDetectionStrategy.OnPush` — every async state update must be followed by `this.cdRef.detectChanges()`, matching the pattern already used in `make-year-plant-form.component.ts`. +- New history table shows only these 7 field_keys (plus datetime): `budget_plan_name_th`, `project_name_th`, `budget_project_name_th`, `budget_strategy_name_th`, `budget_topic_name_th`, `responsible_faculty_name_th`, `budget_come_from`. Do not add `a_1`-`a_13`/`p_1`-`p_13` or `budget_location_name_th` — out of scope per the approved design spec. +- Failed `getHistory()` calls degrade silently (`catchError(() => of([]))`) — no `Swal` error popups on this read-only report page. + +--- + +### Task 1: Extract `buildHistorySessions`/`countEditSessions` into a shared util + +**Files:** +- Create: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/core/utils/change-history-session.util.ts` +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts` + +**Interfaces:** +- Consumes: nothing new +- Produces: `buildHistorySessions(records: any[]): HistorySession[]` and `countEditSessions(history: any[]): number`, plus the `HistorySession` interface (`{datetime: string, isOriginal: boolean, vals: Record}`) — Task 2 imports all three. + +- [ ] **Step 1: Create the util file** + +```ts +export interface HistorySession { + datetime: string + isOriginal: boolean + vals: Record +} + +export function buildHistorySessions(records: any[]): HistorySession[] { + const sessionMap = new Map() + for (const r of records) { + const isOriginal = r.change_remark === 'ต้นฉบับ' + const key = isOriginal ? '__original__' : (r.created_datetime ?? '').substring(0, 19) + if (!sessionMap.has(key)) { + sessionMap.set(key, { datetime: r.created_datetime, isOriginal, vals: {} }) + } + sessionMap.get(key)!.vals[r.field_key] = r.new_value + } + const entries = Array.from(sessionMap.values()) + const originals = entries.filter(e => e.isOriginal) + const edits = entries.filter(e => !e.isOriginal) + return [...edits, ...originals] +} + +export function countEditSessions(history: any[]): number { + if (!history?.length) return 0 + const keys = new Set( + history + .filter(h => h.change_remark !== 'ต้นฉบับ') + .map(h => (h.created_datetime ?? '').substring(0, 19)) + ) + return keys.size +} +``` + +- [ ] **Step 2: Add the import to `make-year-plant-form.component.ts`** + +Find, at the top of the file: +```ts +import { ChangeProjectService } from 'src/app/core/service/request-budget/change-project.service'; +``` +Change to: +```ts +import { ChangeProjectService } from 'src/app/core/service/request-budget/change-project.service'; +import { buildHistorySessions, countEditSessions } from 'src/app/core/utils/change-history-session.util'; +``` + +- [ ] **Step 3: Remove the private `countEditSessions` method** + +Find: +```ts + private countEditSessions(history: any[]): number { + if (!history?.length) return 0 + const keys = new Set( + history + .filter(h => h.change_remark !== 'ต้นฉบับ') + .map(h => (h.created_datetime ?? '').substring(0, 19)) + ) + return keys.size + } + + getEditCount(uid: string): number { +``` +Change to: +```ts + getEditCount(uid: string): number { +``` + +- [ ] **Step 4: Remove the private `buildHistorySessions` method** + +Find: +```ts + private buildHistorySessions(records: any[]): Array<{datetime: string, isOriginal: boolean, vals: Record}> { + const sessionMap = new Map}>() + for (const r of records) { + const isOriginal = r.change_remark === 'ต้นฉบับ' + const key = isOriginal ? '__original__' : (r.created_datetime ?? '').substring(0, 19) + if (!sessionMap.has(key)) { + sessionMap.set(key, { datetime: r.created_datetime, isOriginal, vals: {} }) + } + sessionMap.get(key)!.vals[r.field_key] = r.new_value + } + const entries = Array.from(sessionMap.values()) + const originals = entries.filter(e => e.isOriginal) + const edits = entries.filter(e => !e.isOriginal) + return [...edits, ...originals] + } + + toggleHistory(d: any) { +``` +Change to: +```ts + toggleHistory(d: any) { +``` + +- [ ] **Step 5: Repoint every call site to the imported functions** + +Using the Edit tool with `replace_all: true` on `make-year-plant-form.component.ts`: +- Replace `this.buildHistorySessions(` with `buildHistorySessions(` (4 occurrences: inside `saveProject()`, `toggleHistory()`, `toggleResearchHistory()`, `saveResearchProject()`) +- Replace `this.countEditSessions(` with `countEditSessions(` (4 occurrences: inside the initial project-history batch load, `saveProject()`, the initial research-history batch load, `saveResearchProject()`) + +- [ ] **Step 6: Verify no stray references remain** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && grep -n "this.buildHistorySessions\|this.countEditSessions" src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts` +Expected: no output (all call sites repointed). + +- [ ] **Step 7: Build to confirm no errors** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production` +Expected: build succeeds with no new errors. + +- [ ] **Step 8: Commit** + +```bash +git add rmutr-web/src/app/core/utils/change-history-session.util.ts rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/form/make-year-plan-form/make-year-plant-form.component.ts +git commit -m "refactor: extract buildHistorySessions/countEditSessions into a shared util" +``` + +--- + +### Task 2: `List14ResearchComponent` — history fetch state + +**Files:** +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.ts` + +**Interfaces:** +- Consumes: `buildHistorySessions`, `countEditSessions`, `HistorySession` (Task 1); `ChangeProjectResearchDetailService.getHistory(uid): Observable` (existing, `change-project-research-detail.service.ts:14`) +- Produces: `getEditCount(uid): number`, `toggleHistory(uid): void`, `isHistoryLoading(uid): boolean`, `getHistorySessions(uid): HistorySession[]`, `expandedHistoryUid: string | null` — Task 3's template binds to all of these. + +- [ ] **Step 1: Replace the component with the history-aware version** + +Find the full current file content: +```ts +import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core'; +import { PageEvent } from '@angular/material/paginator'; +import { BaseList } from 'src/app/core/base/base-list'; + +@Component({ + selector: 'app-list14-research', + templateUrl: './list14-research.component.html', + styleUrls: ['./list14-research.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class List14ResearchComponent extends BaseList implements OnInit, OnChanges { + + @Input() dataSource: any = []; + @Output() onchange = new EventEmitter(); + @Output() onedit = new EventEmitter(); + @Output() ondelete = new EventEmitter(); + @Output() onexcel = new EventEmitter(); + @Output() onReport = new EventEmitter(); + data; + details: any[] = []; + + constructor() { super(); } + + ngOnChanges(changes: SimpleChanges): void { + if ('dataSource' in changes && changes?.dataSource?.currentValue) { + this.data = this.dataSource; + this.details = this.dataSource ? [...this.dataSource] : []; + } + } + + ngOnInit(): void {} + + edit(val) { this.onedit.emit(val.change_project_research_uid); } + delete(val) { this.ondelete.emit(val); } + + change(event: PageEvent) { + let page: number = event.pageIndex + 1; + let table: any = { page: page, size: event.pageSize }; + this.onchange.emit(table); + } + + excel(val) { this.onexcel.emit(val); } + report(el) { this.onReport.emit({ change_project_research_uid: el }); } +} +``` +Change to: +```ts +import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core'; +import { PageEvent } from '@angular/material/paginator'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { BaseList } from 'src/app/core/base/base-list'; +import { ChangeProjectResearchDetailService } from 'src/app/core/service/request-budget/change-project-research-detail.service'; +import { buildHistorySessions, countEditSessions, HistorySession } from 'src/app/core/utils/change-history-session.util'; + +@Component({ + selector: 'app-list14-research', + templateUrl: './list14-research.component.html', + styleUrls: ['./list14-research.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class List14ResearchComponent extends BaseList implements OnInit, OnChanges { + + @Input() dataSource: any = []; + @Output() onchange = new EventEmitter(); + @Output() onedit = new EventEmitter(); + @Output() ondelete = new EventEmitter(); + @Output() onexcel = new EventEmitter(); + @Output() onReport = new EventEmitter(); + data; + details: any[] = []; + + editCountMap = new Map(); + historySessionsMap = new Map(); + historyLoadingUids = new Set(); + expandedHistoryUid: string | null = null; + + constructor( + private cdRef: ChangeDetectorRef, + private ChangeProjectResearchDetailSV: ChangeProjectResearchDetailService + ) { super(); } + + ngOnChanges(changes: SimpleChanges): void { + if ('dataSource' in changes && changes?.dataSource?.currentValue) { + this.data = this.dataSource; + this.details = this.dataSource ? [...this.dataSource] : []; + this.loadHistoryCounts(this.details); + } + } + + ngOnInit(): void {} + + private loadHistoryCounts(details: any[]): void { + const uids = (details || []) + .map((d: any) => d.change_project_research_detail_uid) + .filter((uid: string) => !!uid); + if (uids.length === 0) return; + forkJoin(uids.map((uid: string) => + this.ChangeProjectResearchDetailSV.getHistory(uid).pipe(catchError(() => of([] as any[]))) + )).subscribe((histories: any[][]) => { + this.editCountMap = new Map(); + uids.forEach((uid: string, i: number) => { + const count = countEditSessions(histories[i] ?? []); + if (count > 0) this.editCountMap.set(uid, count); + }); + this.cdRef.detectChanges(); + }); + } + + getEditCount(uid: string): number { + return this.editCountMap.get(uid) ?? 0; + } + + toggleHistory(uid: string): void { + if (!uid) return; + if (this.expandedHistoryUid === uid) { + this.expandedHistoryUid = null; + this.cdRef.detectChanges(); + return; + } + this.expandedHistoryUid = uid; + if (!this.historySessionsMap.has(uid)) { + this.historyLoadingUids.add(uid); + this.ChangeProjectResearchDetailSV.getHistory(uid).pipe( + catchError(() => of([] as any[])) + ).subscribe((h: any[]) => { + this.historySessionsMap.set(uid, buildHistorySessions(h)); + this.historyLoadingUids.delete(uid); + this.cdRef.detectChanges(); + }); + } + this.cdRef.detectChanges(); + } + + isHistoryLoading(uid: string): boolean { + return this.historyLoadingUids.has(uid); + } + + getHistorySessions(uid: string): HistorySession[] { + return this.historySessionsMap.get(uid) ?? []; + } + + edit(val) { this.onedit.emit(val.change_project_research_uid); } + delete(val) { this.ondelete.emit(val); } + + change(event: PageEvent) { + let page: number = event.pageIndex + 1; + let table: any = { page: page, size: event.pageSize }; + this.onchange.emit(table); + } + + excel(val) { this.onexcel.emit(val); } + report(el) { this.onReport.emit({ change_project_research_uid: el }); } +} +``` + +- [ ] **Step 2: Build to confirm no errors** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production` +Expected: build succeeds with no new errors. + +- [ ] **Step 3: Commit** + +```bash +git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.ts +git commit -m "feat: fetch and track edit-history counts/sessions in List14ResearchComponent" +``` + +--- + +### Task 3: Template — history icon/badge + expandable session table + +**Files:** +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.html` +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.scss` + +**Interfaces:** +- Consumes: `getEditCount(uid)`, `toggleHistory(uid)`, `isHistoryLoading(uid)`, `getHistorySessions(uid)`, `expandedHistoryUid` (Task 2) + +- [ ] **Step 1: Add the history icon + badge to the "ลำดับ" cell** + +Find: +```html + + + + {{i+1}} + {{detail.budget_year_name_th}} +``` +Change to: +```html + + + + +
+ {{i+1}} + + + เปลี่ยนแปลง #{{getEditCount(detail.change_project_research_detail_uid)}} + +
+ + {{detail.budget_year_name_th}} +``` + +- [ ] **Step 2: Add the expand row after the "A" row** + +Find: +```html + + A + {{detail.a_1}} + {{detail.a_2}} + {{detail.a_3}} + {{detail.a_4}} + {{detail.a_5}} + {{detail.a_6}} + {{detail.a_7}} + {{detail.a_8}} + {{detail.a_9 | date:'dd/MM/yyyy'}} + {{detail.a_10 | date:'dd/MM/yyyy'}} + {{detail.a_11}} + {{detail.a_12}} + {{detail.a_13}} + +
+ + +``` +Change to: +```html + + A + {{detail.a_1}} + {{detail.a_2}} + {{detail.a_3}} + {{detail.a_4}} + {{detail.a_5}} + {{detail.a_6}} + {{detail.a_7}} + {{detail.a_8}} + {{detail.a_9 | date:'dd/MM/yyyy'}} + {{detail.a_10 | date:'dd/MM/yyyy'}} + {{detail.a_11}} + {{detail.a_12}} + {{detail.a_13}} + + + +
+
ประวัติการแก้ไข
+
+ + กำลังโหลด... +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
วันเวลาที่แก้ไขแผนงานชื่อโครงการผลผลิตประเด็นยุทธศาสตร์ลักษณะโครงการหน่วยงานที่รับผิดชอบแหล่งที่มาของเงิน
+ ต้นฉบับ + {{session.datetime | date:'dd/MM/yyyy HH:mm'}} + {{ session.vals['budget_plan_name_th'] }}{{ session.vals['project_name_th'] }}{{ session.vals['budget_project_name_th'] }}{{ session.vals['budget_strategy_name_th'] }}{{ session.vals['budget_topic_name_th'] }}{{ session.vals['responsible_faculty_name_th'] }}{{ session.vals['budget_come_from'] }}
ไม่มีประวัติการแก้ไข
+
+
+
+ + +
+ + +``` + +- [ ] **Step 3: Append the seq-cell/badge styles to the component's scss** + +Find, at the end of `list14-research.component.scss` (after the `.text-right { text-align: right; }` block): +```scss +.text-right { + text-align: right; +} +``` +Change to: +```scss +.text-right { + text-align: right; +} + +// ===== Sequence number cell (history icon + edit-count badge) ===== +.seq-cell { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px 4px; +} + +.seq-number { + font-size: 15px; + font-weight: 700; + color: #2c3e50; + line-height: 1; +} + +.seq-btn { + width: 24px !important; + height: 24px !important; + line-height: 24px !important; + + mat-icon { + font-size: 16px !important; + width: 16px !important; + height: 16px !important; + line-height: 16px !important; + } + + &.seq-btn--history { + color: #90a4ae !important; + + &:hover { + color: #0C7469 !important; + } + } +} + +.edit-count-pill { + font-size: 9px; + font-weight: 500; + border-radius: 10px; + padding: 2px 6px; + white-space: nowrap; + background: #f0f0f0; + color: #bbb; + letter-spacing: 0.2px; + line-height: 1.4; + + &.edit-count-pill--active { + background: #e8f0fe; + color: #1a73e8; + font-weight: 600; + } +} +``` + +- [ ] **Step 4: Build to confirm no errors** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production` +Expected: build succeeds with no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.html rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list14-research/list14-research.component.scss +git commit -m "feat: render edit-history icon, badge, and session table on expense-project-research list" +``` + +--- + +### Task 4: End-to-end manual verification + +**Files:** none (verification only) + +**Interfaces:** none + +- [ ] **Step 1: Start the dev server** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && npm start` + +- [ ] **Step 2: Create a history entry** + +Open "การบริหารและรายงานผล > เปลี่ยนแปลงโครงการ(วิจัย)" (`change-project-research-form/list-all`) → find a research project row with status allowing edits → change "ชื่อโครงการ" and "แผนงาน" → click "ส่งข้อมูล". + +- [ ] **Step 3: Confirm the badge appears on expense-project-research** + +Open the menu that routes to `expense-project-research` (typeUrl 26). +Expected: the row for the project just edited shows a history icon and badge "เปลี่ยนแปลง #1" in the "ลำดับ" column; rows never edited show neither. + +- [ ] **Step 4: Confirm the expand row content and ordering** + +Click the history icon. +Expected: row expands showing a table with 2 rows — the edit session (newest) first, then "ต้นฉบับ" last. In the edit-session row, "ชื่อโครงการ" and "แผนงาน" cells are highlighted pale yellow and show the new values; other columns (ผลผลิต, ประเด็นยุทธศาสตร์, ลักษณะโครงการ, หน่วยงานที่รับผิดชอบ, แหล่งที่มาของเงิน) are empty/not highlighted. The "ต้นฉบับ" row has no highlighted cells. + +- [ ] **Step 5: Confirm a second edit appends correctly** + +Back in `change-project-research-form/list-all`, edit the same project again, this time changing "ผลผลิต" only → "ส่งข้อมูล". Return to `expense-project-research`. +Expected: badge now reads "เปลี่ยนแปลง #2"; expanding shows 3 rows (2 edit sessions newest-first, then "ต้นฉบับ" still last); only "ผลผลิต" is highlighted in the newest row, only "ชื่อโครงการ"/"แผนงาน" highlighted in the older one. + +- [ ] **Step 6: Loading state** + +With browser devtools network throttled (Slow 3G), collapse and re-click the history icon on a row whose sessions aren't cached yet (or hard-refresh the page first). +Expected: brief "กำลังโหลด..." spinner shows before the session table renders. + +- [ ] **Step 7: Regression check — typeUrl 12 history still works** + +Open "เปลี่ยนแปลงโครงการ(วิจัย)" (`change-project-research-form/list-all`) again. +Expected: the pre-existing history icon/badge/expand-row for research rows still work exactly as before (P/A numeric table, original pinned last) — unaffected by the Task 1 util extraction. + +- [ ] **Step 8: No-history rows unaffected** + +On `expense-project-research`, confirm rows for projects that were never edited via `change-project-research-form` show no icon, no badge, and the "ลำดับ" cell shows just the row number.