# 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.