# ส่งอนุมัติเพิ่มเติม (Additional Approval Round) 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:** Let a submitter start a fresh approval round for a (budget_year, faculty, area) request that has already been fully approved, without touching the already-approved round's history, and show users which round (normal / additional) they're looking at. **Architecture:** Add a `round_no` column to `t_approve_state` and `t_remark_history` (default 1, so all existing data becomes "round 1" for free). A new backend endpoint `resubmit` clones the admin's current approval-chain config into a fresh batch of `t_approve_state` rows tagged with the next round number, reusing the exact cloning code already used when a request is first created. The existing `approve`/`reject` endpoints get a small round-scoping fix so they don't accidentally touch an old, already-resolved round's rows once a second round exists. The frontend adds a "ส่งอนุมัติเพิ่มเติม" button, a round badge, and scopes its remark-history card and status checks to the current round. **Tech Stack:** Backend: ASP.NET Core 8 / EF Core 8 / PostgreSQL (`rmutr-api`, DbContext class `Db`). Frontend: Angular 17 (`rmutr-web`). **Spec:** `docs/superpowers/specs/2026-08-19-request-budget-additional-approval-round-design.md` ## Global Constraints - Scope is strictly the request_expense/"03" approval flow at `/app/request-budget` — do not touch `invest_asset`/`invest_construct` approve_state branches. - Unlimited additional rounds allowed (no cap in code). - Neither repo has working test infrastructure for the files this plan touches (`rmutr-api` has no test project at all; `rmutr-web`'s `.spec.ts` files for these components are unconfigured Angular-CLI stubs with no providers for the components' many injected services). Each task's "test cycle" is therefore: build/compile clean, then a manual verification step described in the task — this matches how every other fix in this session was verified, and is called out explicitly per task rather than silently skipped. - Every backend task must end with `dotnet build rmutr-budget-api.csproj -c Release` reporting `0 Error(s)` before moving on. - Every frontend task must end with `node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/` reporting exit code 0 before moving on. - `~/.dotnet/tools` is not on PATH in a fresh shell in this environment — invoke `dotnet-ef` via `~/.dotnet/tools/dotnet-ef` or `export PATH="$PATH:$HOME/.dotnet/tools"` first. - Deploy backend (`rmutr-api/script.sh`) before frontend (`rmutr-web/script.sh`) — the new frontend button calls an endpoint that must exist first. Both scripts use `sshpass`/`expect` with password `@Dmin2241` against `k8-node-1@172.27.30.135`; frontend previously deployed via `SSHPASS='@Dmin2241' sshpass -e bash script.sh`, backend via plain `bash script.sh` (uses `expect` internally). - **Back up the production database before Task 3's deploy** — it's the first deploy that runs a schema migration (`Database.Migrate()` auto-applies on backend startup, per `Startup.cs:444-445`). --- ## Task 1: Backend — add `round_no` column (models + migration) **Files:** - Modify: `Modules/Setting/Databases/Models/approve_state.cs` - Modify: `Modules/Requests/Databases/Models/remark_history.cs` - Create: `Migrations/{timestamp}_AddRoundNoToApproveStateAndRemarkHistory.cs` (+ `.Designer.cs`, generated by EF tooling) - Modify: `Migrations/DbModelSnapshot.cs` (or module-specific snapshot — generated by EF tooling) **Interfaces:** - Produces: `approve_state.round_no` (`int`, default `1`) and `remark_history.round_no` (`int`, default `1`), available on `t_approve_state`/`v_approve_state` and `t_remark_history`/`v_remark_history` since both inherit from these base classes. Task 2 and the frontend tasks read/write this property. - [ ] **Step 1: Add `round_no` to the `approve_state` base class** Edit `Modules/Setting/Databases/Models/approve_state.cs`. Current tail of the `approve_state` class: ```csharp public string position_name_th { get; set; } // public int? type_id { get; set; } } ``` Change to: ```csharp public string position_name_th { get; set; } public int round_no { get; set; } = 1; // public int? type_id { get; set; } } ``` - [ ] **Step 2: Add `round_no` to the `remark_history` base class** Edit `Modules/Requests/Databases/Models/remark_history.cs`. Current tail of the `remark_history` class: ```csharp public DateTime? updated_date { get; set; } public string updated_name { get; set; } } ``` Change to: ```csharp public DateTime? updated_date { get; set; } public string updated_name { get; set; } public int round_no { get; set; } = 1; } ``` - [ ] **Step 3: Generate the EF Core migration** Run from `/Users/nut.looknut/Project/rmutr/rmutr-api`: ```bash export PATH="$PATH:$HOME/.dotnet/tools" dotnet ef migrations add AddRoundNoToApproveStateAndRemarkHistory --context Db ``` Expected: command prints `Done.` and creates two new files under `Migrations/` named `{yyyyMMddHHmmss}_AddRoundNoToApproveStateAndRemarkHistory.cs` and the matching `.Designer.cs`, plus updates the snapshot file(s) EF maintains for the `Db` context. - [ ] **Step 4: Inspect the generated migration's `Up()` method** Open the new `{timestamp}_AddRoundNoToApproveStateAndRemarkHistory.cs` file and confirm `Up()` contains exactly two `migrationBuilder.AddColumn` calls — one for table `t_approve_state` (or whatever the mapped table name is, check against `AddColumn` calls for `position_name_th` in earlier migrations for the exact table-name casing this project uses) column `round_no`, and one for `t_remark_history` column `round_no`, both with `defaultValue: 1`. If EF generated anything else (e.g. it picked up unrelated pending model changes), stop and investigate before proceeding — do not hand-edit the migration to remove unrelated changes without understanding why they appeared. - [ ] **Step 5: Build to confirm everything compiles** ```bash dotnet build rmutr-budget-api.csproj -c Release ``` Expected: `0 Error(s)` (warning count may match the pre-existing baseline of 244 warnings from before this plan — new warnings related to `round_no` would indicate a mistake). - [ ] **Step 6: Commit** ```bash git add Modules/Setting/Databases/Models/approve_state.cs Modules/Requests/Databases/Models/remark_history.cs Migrations/ git commit -m "feat: add round_no column to t_approve_state and t_remark_history Enables tracking multiple approval rounds per request_expense — existing rows default to round_no=1. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK" ``` --- ## Task 2: Backend — round-aware `getApproveState`, round-scoped approve/reject, new `resubmit` endpoint **Files:** - Modify: `Modules/Setting/Services/ApproveState.cs` - Modify: `Modules/Setting/Controllers/ApproveState.cs` **Interfaces:** - Consumes: `t_approve_state.round_no` (from Task 1). - Produces: `ApproveStateService.getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid, string code, ClaimsIdentity claimsIdentity, int roundNo = 1)` (new optional 6th parameter — existing callers unaffected). New route `GET /api/setting/approve_state/resubmit/{uid:guid}` returning `ActionResult`, consumed by frontend Task 4. - [ ] **Step 1: Add the `roundNo` parameter to `getApproveState` and stamp it on cloned rows** In `Modules/Setting/Services/ApproveState.cs`, change the method signature (currently): ```csharp public async Task getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid, string code, ClaimsIdentity claimsIdentity) { ``` to: ```csharp public async Task getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid, string code, ClaimsIdentity claimsIdentity, int roundNo = 1) { ``` Then inside the method, in the `.Select(c => { var result = new t_approve_state() { ... } ...})` block, add `round_no = roundNo,` to the object initializer (after `position_name_th = c.position_name_th,`): ```csharp var result = new t_approve_state() { approve_state_uid = Guid.NewGuid(), personnel_uid = c.personnel_uid, display_name_th = c.display_name_th, row_order = c.row_order, is_state = false, request_approve_type = request_approve_type.request_approve_type_code, position_name_th = c.position_name_th, round_no = roundNo, }; ``` - [ ] **Step 2: Add round-scoping to `ApproveGetEntity`'s `request_expense` branch** In `Modules/Setting/Controllers/ApproveState.cs`, the `if (request_expense != null)` block inside `ApproveGetEntity` currently reads (after the earlier area_uid permission fix): ```csharp if (request_expense != null) { getResult = await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.is_state == true); if (getResult != null) { // ขั้นแรก (หน่วยงานส่ง row_order==1): ไม่บังคับสิทธิ์ที่แอดมินตั้งไว้แล้ว // แต่บังคับว่าผู้ส่งต้องอยู่ "พื้นที่" เดียวกับคำขอนี้ (area_uid ของผู้ใช้ == // budget_location_uid ของคำขอ) เพราะเจ้าหน้าที่ส่วนกลาง (เช่น กองนโยบายและแผน) // คีย์/ส่งคำขอแทนหลายคณะในพื้นที่เดียวกันได้ ไม่ได้จำกัดแค่คณะต้นสังกัดตัวเอง if (getResult.row_order == 1) { var userAreaUidRaw = (User.Identity as ClaimsIdentity)?.FindFirst("area_uid")?.Value; var isOwnArea = !string.IsNullOrEmpty(userAreaUidRaw) && Guid.TryParse(userAreaUidRaw, out var userAreaUid) && request_expense.budget_location_uid == userAreaUid; if (!isOwnArea) { return StatusCode(StatusCodes.Status403Forbidden, "ไม่มีสิทธิ์ส่งอนุมัติ (พื้นที่ของผู้ใช้ไม่ตรงกับคำขอนี้)"); } } getResult.is_state = null; getResult.is_approve = true; request_expense.current_approve_name = null; _db.t_approve_state.Update(getResult); _db.t_request_expense.Update(request_expense); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); var check_all_state = await _db.t_approve_state .Where(c => c.request_expense_uid == uid && c.is_approve != true).ToListAsync(); if (!check_all_state.Any()) { request_expense.is_approve = true; _db.t_request_expense.Update(request_expense); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.row_order == getResult.row_order + 1); if (check_getResult != null) { check_getResult.is_state = true; request_expense.current_approve_name = check_getResult.display_name_th; _db.t_request_expense.Update(request_expense); _db.t_approve_state.Update(check_getResult); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } } } ``` Replace the whole block with (adds `currentRound` lookup, filters the three `t_approve_state` queries by it): ```csharp if (request_expense != null) { var currentRound = await _db.t_approve_state .Where(c => c.request_expense_uid == uid) .Select(c => (int?)c.round_no) .MaxAsync() ?? 1; getResult = await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_state == true); if (getResult != null) { // ขั้นแรก (หน่วยงานส่ง row_order==1): ไม่บังคับสิทธิ์ที่แอดมินตั้งไว้แล้ว // แต่บังคับว่าผู้ส่งต้องอยู่ "พื้นที่" เดียวกับคำขอนี้ (area_uid ของผู้ใช้ == // budget_location_uid ของคำขอ) เพราะเจ้าหน้าที่ส่วนกลาง (เช่น กองนโยบายและแผน) // คีย์/ส่งคำขอแทนหลายคณะในพื้นที่เดียวกันได้ ไม่ได้จำกัดแค่คณะต้นสังกัดตัวเอง if (getResult.row_order == 1) { var userAreaUidRaw = (User.Identity as ClaimsIdentity)?.FindFirst("area_uid")?.Value; var isOwnArea = !string.IsNullOrEmpty(userAreaUidRaw) && Guid.TryParse(userAreaUidRaw, out var userAreaUid) && request_expense.budget_location_uid == userAreaUid; if (!isOwnArea) { return StatusCode(StatusCodes.Status403Forbidden, "ไม่มีสิทธิ์ส่งอนุมัติ (พื้นที่ของผู้ใช้ไม่ตรงกับคำขอนี้)"); } } getResult.is_state = null; getResult.is_approve = true; request_expense.current_approve_name = null; _db.t_approve_state.Update(getResult); _db.t_request_expense.Update(request_expense); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); var check_all_state = await _db.t_approve_state .Where(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_approve != true) .ToListAsync(); if (!check_all_state.Any()) { request_expense.is_approve = true; _db.t_request_expense.Update(request_expense); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.round_no == currentRound && c.row_order == getResult.row_order + 1); if (check_getResult != null) { check_getResult.is_state = true; request_expense.current_approve_name = check_getResult.display_name_th; _db.t_request_expense.Update(request_expense); _db.t_approve_state.Update(check_getResult); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } } } ``` - [ ] **Step 3: Add round-scoping to `RejectGetEntity`'s `request_expense` branch** In the same file, the `if (request_expense_approve != null)` block inside `RejectGetEntity` currently reads: ```csharp if (request_expense_approve != null) { getResult = request_expense_approve; var result_data_lists = await _db.t_approve_state .Where(c => c.request_expense_uid == uid) .OrderBy(o => o.row_order).ToListAsync(); var index_current = result_data_lists.FindIndex(c => c.is_state == true); if (index_current > 0) { result_data_lists[index_current].is_state = null; result_data_lists[index_current].is_approve = null; result_data_lists[index_current - 1].is_state = true; result_data_lists[index_current - 1].is_approve = null; } // var result_data = result_data_lists.Select(c => // { // c.is_state = null; // c.is_approve = null; // if (c.row_order == 1) // { // c.is_state = true; // } // return c; // }).ToList(); _db.t_approve_state.UpdateRange(result_data_lists); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid); if (request_expense != null) { request_expense.current_approve_name = null; var approve = await _db.t_approve_state .Where(c => c.request_expense_uid == uid && c.is_state == true).OrderBy(c => c.row_order) .ToListAsync(); if (approve.Count > 0) { request_expense.current_approve_name = approve.First().display_name_th; } request_expense.is_draft = true; request_expense.is_approve = false; _db.t_request_expense.Update(request_expense); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } } ``` Replace with (adds `currentRound` lookup, filters the `Where(request_expense_uid == uid)` list-fetch and the later `approve` re-check by it — the `request_expense_approve` variable that gated entry into this branch also needs the same round filter, see Step 3b below): ```csharp if (request_expense_approve != null) { getResult = request_expense_approve; var currentRound = request_expense_approve.round_no; var result_data_lists = await _db.t_approve_state .Where(c => c.request_expense_uid == uid && c.round_no == currentRound) .OrderBy(o => o.row_order).ToListAsync(); var index_current = result_data_lists.FindIndex(c => c.is_state == true); if (index_current > 0) { result_data_lists[index_current].is_state = null; result_data_lists[index_current].is_approve = null; result_data_lists[index_current - 1].is_state = true; result_data_lists[index_current - 1].is_approve = null; } _db.t_approve_state.UpdateRange(result_data_lists); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid); if (request_expense != null) { request_expense.current_approve_name = null; var approve = await _db.t_approve_state .Where(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_state == true) .OrderBy(c => c.row_order) .ToListAsync(); if (approve.Count > 0) { request_expense.current_approve_name = approve.First().display_name_th; } request_expense.is_draft = true; request_expense.is_approve = false; _db.t_request_expense.Update(request_expense); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } } ``` (Dropped the already-commented-out dead code block during this edit — it was inert and unrelated to the round fix.) - [ ] **Step 3b: Round-scope the query that finds `request_expense_approve` itself** A few lines earlier in `RejectGetEntity`, the initial lookup reads: ```csharp var request_expense_approve = await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.is_state == true); ``` This one does **not** need a round filter — `is_state == true` is already unique per `request_expense_uid` across all rounds combined (only the live round ever has a row with `is_state == true`; every completed round's rows are left with `is_state == null`). Leave this line unchanged. (Documented here so the round_no on `request_expense_approve`, used as `currentRound` in Step 3, is trustworthy — it always belongs to the one truly-active round.) - [ ] **Step 4: Add the `resubmit` endpoint** In `Modules/Setting/Controllers/ApproveState.cs`, add a new action inside `ApproveStateController`, directly after `RejectGetEntity` (before the closing `}` of the class): ```csharp [Authorize] [HttpGet("resubmit/{uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> ResubmitGetEntity([FromRoute] Guid uid) { var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid); if (request_expense == null) { return NotFound(); } if (request_expense.is_approve != true) { return BadRequest("คำขอนี้ยังไม่ได้รับการอนุมัติครบทุกขั้น ไม่สามารถส่งอนุมัติเพิ่มเติมได้"); } var currentMaxRound = await _db.t_approve_state .Where(c => c.request_expense_uid == uid) .Select(c => (int?)c.round_no) .MaxAsync() ?? 0; var nextRound = currentMaxRound + 1; request_expense.is_approve = null; request_expense.current_approve_name = await _entityUidService.getApproveState( uid, request_expense.faculty_uid, request_expense.budget_location_uid, "03", User.Identity as ClaimsIdentity, nextRound); _db.t_request_expense.Update(request_expense); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); var getResult = await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.round_no == nextRound && c.row_order == 1); return Ok(getResult); } ``` - [ ] **Step 5: Build to confirm everything compiles** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet build rmutr-budget-api.csproj -c Release ``` Expected: `0 Error(s)`. - [ ] **Step 6: Commit** ```bash git add Modules/Setting/Services/ApproveState.cs Modules/Setting/Controllers/ApproveState.cs git commit -m "feat: round-scope approve/reject and add resubmit endpoint approve/reject's request_expense branch now filters t_approve_state queries by the current round_no, so a second approval round's rows can never collide with the first round's already-resolved rows. Added GET approve_state/resubmit/{uid} to start a fresh round once the current one is fully approved, reusing getApproveState's existing clone-from-admin-config logic with a new roundNo parameter. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK" ``` --- ## Task 3: Backend — deploy to production **Files:** none (deploy only) **Interfaces:** - Consumes: Task 1 + Task 2's committed backend changes. - Produces: live `GET /api/setting/approve_state/resubmit/{uid}` endpoint and round-scoped approve/reject on production, with the `round_no` column added to the production database (auto-applied by `Database.Migrate()` on service start). - [ ] **Step 1: Back up the production database** SSH to the server and take a `pg_dump` before restarting the service (the restart auto-applies the migration): ```bash SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \ "pg_dump -Fc \$(grep -oP '(?<=Database=)[^;]+' /var/www/rmutr-budget-api/appsettings.json 2>/dev/null || echo rmutr_budget) > /tmp/rmutr_budget_pre_round_no_$(date +%Y%m%d_%H%M%S).dump 2>&1 || echo BACKUP_FAILED" ``` If this prints `BACKUP_FAILED` or an error, stop and manually determine the correct database name/credentials (check `/var/www/rmutr-budget-api/appsettings.json`'s `databases:postgres` connection string on the server) before proceeding — do not deploy a migration without a confirmed-good backup. - [ ] **Step 2: Deploy** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api bash script.sh ``` Expected: script completes printing `==> Done` (uploads `rmutr-budget-api.dll`, restarts the `rmutr-budget-api` systemd service). Note from this session's experience: the `scp` step can appear to stall for a long time under poor network conditions without actually failing — if the command seems stuck, verify via a separate terminal/session whether the remote file timestamp and `systemctl show rmutr-budget-api --property=ActiveEnterTimestamp` have already moved forward before concluding it's actually hung. - [ ] **Step 3: Verify the service restarted healthy and the migration applied** ```bash SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \ "systemctl status rmutr-budget-api --no-pager | head -10" ``` Expected: `Active: active (running)` with a recent `ActiveEnterTimestamp`. Then confirm the column exists: ```bash SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \ "journalctl -u rmutr-budget-api --since '5 minutes ago' --no-pager | grep -i 'migrat\|round_no\|error\|exception' | tail -30" ``` Expected: no exception/error lines related to migration; absence of output here is fine (it just means nothing noteworthy was logged, which is normal for a clean migration). --- ## Task 4: Frontend — service method + round-scoped component logic **Files:** - Modify: `src/app/core/service/request/request-expense.service.ts` - Modify: `src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts` **Interfaces:** - Consumes: `GET /api/setting/approve_state/resubmit/{id}` (from Task 3, now live in production). - Produces: `RequestExpenseService.resubmit(id)`; on `RequestBudgetListComponent` — `currentRound(): number`, `currentRoundApproveStates(): any[]`, `isFullyApproved(): boolean`, `roundLabel(): string`, `currentRoundRemarkHistorys(): any[]`, updated `hasActiveStep(rowOrder)` and `approveStatus()` (both now round-scoped), updated `resole()` (stamps `round_no` on new remarks). Task 5's template and new button consume all of these. - [ ] **Step 1: Add `resubmit()` to `RequestExpenseService`** Edit `src/app/core/service/request/request-expense.service.ts` — current full file: ```ts import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { BaseService } from '../../base/base-service'; import { environment } from 'src/environments/environment'; @Injectable({ providedIn: 'root' }) export class RequestExpenseService extends BaseService { constructor( public http: HttpClient ) { super('/request/request_expense',http); } approve(id) { return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/approve/${id}`) } reject(id) { return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/reject/${id}`) } } ``` Change the closing of the class to add `resubmit`: ```ts approve(id) { return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/approve/${id}`) } reject(id) { return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/reject/${id}`) } resubmit(id) { return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/resubmit/${id}`) } } ``` - [ ] **Step 2: Add `currentRound()` and round-scope `hasActiveStep()`/`approveStatus()`** Edit `src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts`. Current relevant block: ```ts approveStatus(){ let data = this.approve_button?.approve_states.find(x=>x.row_order == 1).is_approve return data } /** * สถานะจริงของขั้นตอนอนุมัติ (is_state/row_order) backend เก็บไว้ใน approve_states[] * เท่านั้น — field is_state/row_order ที่ระดับบนสุดของ approve_button เป็น null เสมอ * (ไม่เคย sync ค่า) ห้ามใช้ approve_button?.is_state ตรงๆ ให้ใช้ hasActiveStep() แทน */ hasActiveStep(rowOrder: number): boolean { return !!this.approve_button?.approve_states?.some(s => s.row_order === rowOrder && s.is_state === true) } ``` Replace with: ```ts approveStatus(){ let data = this.currentRoundApproveStates().find(x=>x.row_order == 1)?.is_approve return data } /** * เลขรอบอนุมัติปัจจุบัน (1 = รอบปกติ, 2+ = รอบเพิ่มเติม) — คำนวณจาก round_no สูงสุดที่มีอยู่ใน * approve_states[] แทนการเก็บ field แยกใน request_expense เอง กัน field ไม่ sync กับข้อมูลจริง */ currentRound(): number { const rounds = this.approve_button?.approve_states?.map(s => s.round_no ?? 1) ?? [1] return Math.max(1, ...rounds) } /** approve_states[] เฉพาะของรอบปัจจุบัน — กันรอบเก่าที่อนุมัติไปแล้วมาปนกับรอบใหม่ */ currentRoundApproveStates(): any[] { const round = this.currentRound() return this.approve_button?.approve_states?.filter(s => (s.round_no ?? 1) === round) ?? [] } /** * สถานะจริงของขั้นตอนอนุมัติ (is_state/row_order) backend เก็บไว้ใน approve_states[] * เท่านั้น — field is_state/row_order ที่ระดับบนสุดของ approve_button เป็น null เสมอ * (ไม่เคย sync ค่า) ห้ามใช้ approve_button?.is_state ตรงๆ ให้ใช้ hasActiveStep() แทน */ hasActiveStep(rowOrder: number): boolean { return this.currentRoundApproveStates().some(s => s.row_order === rowOrder && s.is_state === true) } /** true เมื่ออนุมัติครบทุกขั้นของรอบปัจจุบันแล้ว — ใช้คุมปุ่ม "ส่งอนุมัติเพิ่มเติม" */ isFullyApproved(): boolean { return this.approve_button?.is_approve === true } /** ป้ายบอกรอบ ต่อท้ายข้อความ "สถานะ" — "รอบปกติ" (รอบ 1) หรือ "รอบเพิ่มเติมที่ N" (รอบ ≥2) */ roundLabel(): string { const round = this.currentRound() return round <= 1 ? 'รอบปกติ' : `รอบเพิ่มเติมที่ ${round}` } /** remark_historys[] เฉพาะของรอบปัจจุบัน — กันคอมเมนต์เก่าจากรอบก่อนมาปนกับรอบใหม่ที่เพิ่งเริ่ม */ currentRoundRemarkHistorys(): any[] { const round = this.currentRound() return this.approve_button?.remark_historys?.filter(r => (r.round_no ?? 1) === round) ?? [] } ``` - [ ] **Step 3: Stamp `round_no` on new remarks and round-filter the remark list in `resole()`/`del_array_dec()`** Current `resole()` (relevant "else" branch that adds a brand-new remark) and `del_array_dec()`: ```ts } else { const { value: text } = await Swal.fire({ input: 'textarea', inputValue: ``, inputLabel: 'รายละเอียดที่ส่งแก้ไข', inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...', width: 1200, inputAttributes: { 'aria-label': 'กรอกรายละเอียดที่ส่งแก้ไข' }, showCancelButton: true }) if (text) { Swal.fire('เพิ่มข้อมูลสำเร็จ') console.log(text) let data = { remark_history_name_th: text, updated_name: this.odic.user.profile.display_name, updated_date: new Date(), } this.approve_button.remark_historys.push(data) this.cdRef.detectChanges() } } } del_array_dec(i) { // let form_array = this.form.get('remark_historys') as FormArray // form_array.removeAt(i) this.approve_button.remark_historys.splice(i,1) } ``` Change the `let data = {...}` object literal to include `round_no`: ```ts if (text) { Swal.fire('เพิ่มข้อมูลสำเร็จ') console.log(text) let data = { remark_history_name_th: text, updated_name: this.odic.user.profile.display_name, updated_date: new Date(), round_no: this.currentRound(), } this.approve_button.remark_historys.push(data) this.cdRef.detectChanges() } ``` (`del_array_dec` needs no change — it deletes by array index, and the template in Task 5 will iterate the round-filtered list, so indexes stay consistent within that filtered view. See Task 5 Step 2 for how the template's `*ngFor` switches to a filtered getter.) - [ ] **Step 4: Build to confirm everything compiles** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/ ``` Expected: exit code 0, same warning baseline as before (exceljs/oidc-client/Syncfusion CJS + selector warnings only — no new errors). - [ ] **Step 5: Commit** ```bash git add src/app/core/service/request/request-expense.service.ts src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts git commit -m "feat: round-scoped approval state helpers + resubmit service call hasActiveStep()/approveStatus() now read only the current round's approve_states entries (find() would otherwise match a stale round-1 entry once round 2 exists). Added currentRound()/roundLabel()/ isFullyApproved()/currentRoundRemarkHistorys() and RequestExpenseService.resubmit(). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK" ``` --- ## Task 5: Frontend — template (badge, remark filter, new button) + container wiring **Files:** - Modify: `src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html` - Modify: `src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts` - Modify: `src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts` - Modify: `src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html` **Interfaces:** - Consumes: `currentRound()`, `roundLabel()`, `isFullyApproved()`, `currentRoundRemarkHistorys()` (from Task 4). `nextApproverLabel()` and the `Swal.fire` confirm/success pattern already in `on_approve()` (existing, from earlier in this session). - Produces: new `@Output() onresubmit` event on `RequestBudgetListComponent`, new `on_resubmit()` handler on `RequestBudgetContainer`, wired end to end. - [ ] **Step 1: Add the round badge next to the existing status text** Edit `request-budget-list.component.html`. Current: ```html
สถานะ : อนุมัติแล้ว สถานะ : ส่งแก้ไข
``` Change to: ```html
สถานะ : อนุมัติแล้ว ({{roundLabel()}}) สถานะ : ส่งแก้ไข ({{roundLabel()}})
``` - [ ] **Step 2: Switch the remark-history table to the round-filtered list** Current (the card's `*ngIf` was already fixed earlier this session to require non-empty remarks; this step only changes what list the table iterates and re-derives that `*ngIf` from the same filtered list for consistency): ```html
``` and further down: ```html ``` Change the card's `*ngIf` to: ```html
``` and the `*ngFor` to: ```html ``` `del_array_dec(detailIndex)` inside that loop keeps calling `this.approve_button.remark_historys.splice(i,1)` (unchanged, from Task 4 Step 3's note) — this is safe as long as remark_historys only ever contains one round's worth of *unsaved* entries at a time in practice for this UI, which holds here since the card is only ever shown/editable for the currently active round. No further change needed here. - [ ] **Step 3: Add the "ส่งอนุมัติเพิ่มเติม" button and its output event** In `request-budget-list.component.ts`, add a new `@Output` next to the existing ones. Current: ```ts @Output() onapprove = new EventEmitter(); @Output() onapprove2 = new EventEmitter(); @Output() onreject = new EventEmitter(); ``` Change to: ```ts @Output() onapprove = new EventEmitter(); @Output() onapprove2 = new EventEmitter(); @Output() onreject = new EventEmitter(); @Output() onresubmit = new EventEmitter(); ``` Then add a `resubmit()` method next to the existing `approve()` method. Current: ```ts approve(){ if (!this.query.budget_year_uid) return this.swSV.errText("โปรดเลือกปีงบประมาณ") if (!this.query.faculty_uid) return this.swSV.errText("โปรดเลือกหน่วยงาน/คณะ") this.onapprove.emit(1) } ``` Add directly after it: ```ts resubmit(){ if (!this.query.budget_year_uid) return this.swSV.errText("โปรดเลือกปีงบประมาณ") if (!this.query.faculty_uid) return this.swSV.errText("โปรดเลือกหน่วยงาน/คณะ") this.onresubmit.emit(1) } ``` In `request-budget-list.component.html`, the `keyUrl == 1` button block currently reads: ```html
``` Change to add the new button alongside (shown only once the current round is fully approved, mirroring the existing button's style/permission/period-closed guard): ```html
``` - [ ] **Step 4: Wire the new output through the container template** In `src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html`, find the existing `(onapprove)="on_approve($event)"` binding on `` and add a sibling binding directly after it: ```html (onapprove)="on_approve($event)" (onresubmit)="on_resubmit($event)" ``` - [ ] **Step 5: Add `on_resubmit()` to the container** In `request-budget.container.ts`, `on_approve()` currently reads: ```ts on_approve(el){ const nextApprover = this.nextApproverLabel() Swal.fire({ icon: 'warning', title: 'ต้องการส่งอนุมัติหรือไม่', text: nextApprover ? `ระบบจะส่งคำขอนี้ไปให้ ${nextApprover} เป็นผู้ดำเนินการขั้นถัดไป` : undefined, showCancelButton: true, confirmButtonText: `บันทึก`, cancelButtonText: `ยกเลิก`, }).then((result) => { if (result.isConfirmed) { this.RequestExpenseSV.approve(`${this.approve_button.request_expense_uid}`).pipe( catchError(err => { Swal.fire(err.error.description, '', 'error') return throwError(err) })).subscribe( (res) => { Swal.fire({ title: 'บันทึกสำเร็จ', text: nextApprover ? `ส่งไปให้ ${nextApprover} เรียบร้อยแล้ว` : undefined, icon: 'success', confirmButtonText: 'ตกลง' }).then( (result) => { if (result.isConfirmed) { // this.back() this.onClickSearch(this.searchInput) } } ) }); } }); ``` Add a new method directly after `on_approve()`'s closing (before whatever comes next in the file — the commented-out dead code block that already follows `on_approve()`): ```ts on_resubmit(el){ Swal.fire({ icon: 'warning', title: 'ต้องการส่งอนุมัติเพิ่มเติมหรือไม่', text: 'คำขอนี้อนุมัติครบทุกขั้นไปแล้ว การส่งเพิ่มเติมจะเริ่มรอบอนุมัติใหม่ทั้งสาย (ผู้อนุมัติทุกคนต้องพิจารณาใหม่ รวมรายการเดิมที่เคยอนุมัติแล้วด้วย)', showCancelButton: true, confirmButtonText: `บันทึก`, cancelButtonText: `ยกเลิก`, }).then((result) => { if (result.isConfirmed) { this.RequestExpenseSV.resubmit(`${this.approve_button.request_expense_uid}`).pipe( catchError(err => { Swal.fire(err.error.description, '', 'error') return throwError(err) })).subscribe( (res) => { Swal.fire({ title: 'ส่งอนุมัติเพิ่มเติมสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then( (result) => { if (result.isConfirmed) { this.onClickSearch(this.searchInput) } } ) }); } }); } ``` - [ ] **Step 6: Build to confirm everything compiles** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/ ``` Expected: exit code 0, same warning baseline as before. - [ ] **Step 7: Commit** ```bash git add src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html git commit -m "feat: add ส่งอนุมัติเพิ่มเติม button, round badge, round-filtered remark card Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK" ``` --- ## Task 6: Frontend — deploy to production **Files:** none (deploy only) **Interfaces:** - Consumes: Task 4 + Task 5's committed frontend changes, and Task 3's already-live backend endpoint. - [ ] **Step 1: Deploy** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web SSHPASS='@Dmin2241' sshpass -e bash script.sh ``` Expected: script completes printing `Deployment complete!`. - [ ] **Step 2: Verify the deployed `index.html` timestamp matches** ```bash SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \ "ls -la /var/www/html/budget-web/index.html; date" ``` Expected: `index.html`'s timestamp is within a minute or two of the current time printed by `date`. --- ## Task 7: End-to-end smoke test on production **Files:** none (manual verification only — this repo has no automated test harness for this flow, see Global Constraints) **Interfaces:** - Consumes: everything from Tasks 1–6. - [ ] **Step 1: Confirm existing (round 1) data still renders correctly** Have a user with the right area (e.g. คุณศุภาภรณ์, or the original ศาลายา/คณะวิทยาศาสตร์และเทคโนโลยี/ปีงบ 2569 record referenced throughout this session) load `/app/request-budget` with those filters. Expected: status line now reads `สถานะ : ... (รอบปกติ)`, and behavior otherwise matches what was already verified working before this plan (card + buttons as established in this session). - [ ] **Step 2: Drive that record through full approval if not already fully approved** Using accounts with the right area (row 1) and `SEND-APPROVE` permission (row 2), click through "ส่งอนุมัติ" until `approve_button.is_approve === true` for that record. Expected: at each step, the existing behavior (confirm dialog naming the next approver, success dialog, badge showing "รอบปกติ") continues to work exactly as before — this is the regression check that Task 2's round-scoping didn't break the existing single-round path. - [ ] **Step 3: Trigger an additional round** Once fully approved, reload the page for that same record. Expected: the original "ส่งอนุมัติ" button is gone (as before), a new "ส่งอนุมัติเพิ่มเติม" button appears. Click it, confirm the dialog. Expected: success dialog, page refreshes, status line now reads `... (รอบเพิ่มเติมที่ 2)`, and the "รายละเอียดสำหรับการส่งแก้ไข" card is hidden again (no remarks yet in round 2, even though round 1 may have had some). - [ ] **Step 4: Drive round 2 through to completion and confirm round 1 history is untouched** Approve round 2 through to full completion the same way as Step 2. At any point during this, use the Network tab (same technique used earlier in this session) to inspect the raw `request_expense` API response and confirm `approve_states` contains **both** round 1's rows (untouched, `is_approve: true` throughout) **and** round 2's rows with their own `round_no` — proving history was preserved rather than overwritten. - [ ] **Step 5: Report results** Summarize pass/fail for each step back to the user, including a screenshot or the raw JSON snippet from Step 4 if anything looks off.