docs: add remaining implementation plans

Adds plan docs that were pending in the working tree, matching the
existing docs/superpowers/plans/ pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf6qFtiQoRuSh9zQSPF1wS
This commit is contained in:
Nut.ไปเรื่อย
2026-09-24 19:32:52 +07:00
co-authored by Claude Sonnet 5
parent 24e25f8c9d
commit 4471b224cf
5 changed files with 5128 additions and 0 deletions
@@ -0,0 +1,999 @@
# ส่งอนุมัติเพิ่มเติม (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<int>` 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 <noreply@anthropic.com>
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<v_approve_state>`, 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<string> getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid,
string code, ClaimsIdentity claimsIdentity)
{
```
to:
```csharp
public async Task<string> 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<ActionResult<v_approve_state>> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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
<div class="no-data-detail" *ngIf="keyUrl != 2">
<span *ngIf="approveStatus() == true">สถานะ : อนุมัติแล้ว</span>
<span *ngIf="approveStatus() == false">สถานะ : ส่งแก้ไข</span>
</div>
```
Change to:
```html
<div class="no-data-detail" *ngIf="keyUrl != 2">
<span *ngIf="approveStatus() == true">สถานะ : อนุมัติแล้ว ({{roundLabel()}})</span>
<span *ngIf="approveStatus() == false">สถานะ : ส่งแก้ไข ({{roundLabel()}})</span>
</div>
```
- [ ] **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
<div class="rmutr_card" *ngIf="(keyUrl == 1 && approve_button?.remark_historys?.length > 0) || (keyUrl == 3 && hasActiveStep(2))" >
```
and further down:
```html
<ng-container >
<tr *ngFor="let detail of approve_button.remark_historys;let detailIndex = index">
```
Change the card's `*ngIf` to:
```html
<div class="rmutr_card" *ngIf="(keyUrl == 1 && currentRoundRemarkHistorys().length > 0) || (keyUrl == 3 && hasActiveStep(2))" >
```
and the `*ngFor` to:
```html
<ng-container >
<tr *ngFor="let detail of currentRoundRemarkHistorys();let detailIndex = index">
```
`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
<div class="flex justify-start w-full mt-3 " *ngIf="keyUrl == 1" >
<button mat-raised-button class="buttonColorApprove" *ngIf="hasActiveStep(1) && isOwnArea()" style="color: white;margin-right: 10px;width:120px" (click)="approve()" [disabled]="isPeriodClosed()">ส่งอนุมัติ</button>
</div>
```
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
<div class="flex justify-start w-full mt-3 " *ngIf="keyUrl == 1" >
<button mat-raised-button class="buttonColorApprove" *ngIf="hasActiveStep(1) && isOwnArea()" style="color: white;margin-right: 10px;width:120px" (click)="approve()" [disabled]="isPeriodClosed()">ส่งอนุมัติ</button>
<button mat-raised-button class="buttonColorApprove" *ngIf="isFullyApproved() && isOwnArea()" style="color: white;margin-right: 10px;width:160px" (click)="resubmit()" [disabled]="isPeriodClosed()">ส่งอนุมัติเพิ่มเติม</button>
</div>
```
- [ ] **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 `<app-request-budget-list>` 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 <noreply@anthropic.com>
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.