Files
rmutr/docs/superpowers/plans/2026-09-24-evaluate-collect-approve-workflow.md
T
Nut.ไปเรื่อยandClaude Sonnet 5 028f544652 docs: add implementation plan for evaluate-collect approve workflow
15 tasks covering backend (approve_state/remark_history FK, evaluate_form
status fields, evaluate_form_review table, approve/reject endpoint
branches, central-review controller) and frontend (approver picker,
send-approve, approve/reject UI, review-status card, follow-list
buckets). request_approve_type "09" confirmed free against production.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf6qFtiQoRuSh9zQSPF1wS
2026-09-24 20:32:13 +07:00

2466 lines
112 KiB
Markdown

# evaluate-collect → evaluate-follow Approve Workflow 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 2-level self-service approval chain to `evaluate_form` (evaluate-collect) with a central-review stage on evaluate-follow, mirroring the existing request-budget type_4 approve_state/remark_history/request_budget_review pattern.
**Architecture:** Reuse the existing shared `approve_state`/`remark_history` tables (add an `evaluate_form_uid` FK column to each, new `request_approve_type` code `"09"`), add three status fields to `evaluate_form` (`is_sent`/`is_approve`/`current_approve_name`), extend the shared `ApproveStateController` approve/reject endpoints with a new branch, and add a new `evaluate_form_review` table + controller mirroring `request_budget_review` for the evaluate-follow central-review stage. Frontend ports the exact button/dialog/service pattern already used for คำขอแผ่นดิน (type_4) onto `evaluate-collect-send` and a new review-status component on `evaluate-follow-send`.
**Tech Stack:** ASP.NET Core 8 / EF Core (rmutr-api, branch `master`), Angular 17 standalone-ish module app with Angular Material (rmutr-web, branch `master`). No automated test framework in either repo — verification is manual `curl` + live SQL checks (`python3`+`psycopg2`, since `psql` is not installed locally) + `ng build` + browser walkthrough, per this project's established convention (see `docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md` line 27).
**Spec:** `docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md`
## Global Constraints
- New `request_approve_type` code for this feature is **`"09"`** — confirmed live against the production DB (`rmutr_budget_050625`, the DB the deployed API's `appsettings.json` `"postgres"` connection string actually points at) on 2026-09-24: `SELECT DISTINCT request_approve_type FROM "BUDGET".approve_state` returned `01,02,03,04,05,07,08`, and a full-repo grep of every `request_approve_type ==`/`= "..."` literal confirmed `"06"` is also already reserved (invest_construct self-service) even though no row uses it yet. Re-run Task 1's verification step before writing code, in case another branch claimed `"09"` since this plan was written.
- Approver labels are fixed 2-level, identical to request-budget: `['หัวหน้าหน่วยงาน', 'รองอธิการบดี']` — level 1 required, level 2 optional. Reuse `SetApproversDialogComponent` (`src/app/shared/components/set-approvers-dialog/`) unchanged; it is already generic.
- Identity check on approve/reject must be enforced at **every** `row_order`, not just `row_order == 1` (the bug already fixed for `request_expense` in commit `c12b93bd` — do not reintroduce the narrower check for the new branch).
- Reject during the approval chain steps back **one row_order at a time** (not straight back to the creator) — `row_order == 1` rejecting itself resets `is_sent = false` and returns to the data-entry user; `row_order > 1` rejecting steps back to `row_order - 1` without touching `is_sent`.
- `return_for_edit` on the central-review stage reopens the **last** `approve_state` row (highest `row_order`), not row 1.
- Production DB connection (already in `rmutr-api/appsettings.json`, not a new secret): `Host=203.158.221.28;Username=devadmin;Password=vtwi,yo0tpkd-okfouh;Database=rmutr_budget_050625;port=31620`. All SQL verification steps in this plan use this connection via `python3 -c "import psycopg2; ..."`.
- No `psql` binary is available in this environment — every "verify via SQL" step in this plan uses a small inline `python3` + `psycopg2` script instead.
- Test data must use `created_by = 'test.verify'` (matches the project's established synthetic-data convention, e.g. `docs/superpowers/plans/2026-09-07-invest-asset-self-service-approval.md` line 42) and must be deleted at the end of each backend task's verification.
- Backend `dotnet build` from `/Users/nut.looknut/Project/rmutr/rmutr-api` takes ~8-9 minutes when the worktree is otherwise idle (per this session's own measurement) — check for stray concurrent `dotnet build`/`dotnet publish` processes (`ps aux | grep dotnet`) before starting a build, per this project's known worktree-contention issue.
- Frontend `npm run build` (`ng build --configuration=production`) from `/Users/nut.looknut/Project/rmutr/rmutr-web` takes under a minute when idle; use plain `ng build` (dev config) for faster iteration during a task, and the production build only as the task's final verification step.
- Do all work in a fresh git worktree per repo (`superpowers:using-git-worktrees` convention already used elsewhere in this project, e.g. `.worktrees/feat-request-budget-approve-workflow`), not directly in the primary checkout — this project has had concurrent-session build collisions when that rule was skipped.
## Review Focus
- **Approve/reject called before "ส่งอนุมัติ" was ever clicked.** `SetCustomApprovers` seeds `row_order 1` with `is_state = true` immediately, before `is_sent` is ever set — calling `approve`/`reject` in that window must 400 with "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน", not silently approve. Covered in Task 7.
- **Only one approver configured (row 2 left empty).** The final approval on `row_order 1` alone (no `row_order 2` row exists) must still correctly flip `evaluate_form.is_approve = true`, trigger `final_active`, and insert the pending `evaluate_form_review` row — not get stuck waiting for a `row_order + 1` that will never exist. Covered in Task 7.
- **Wrong person clicks approve/reject.** A JWT whose `display_name` claim does not match `approve_state.display_name_th` for the currently pending row must get 403, at every `row_order` (not just row 1). Covered in Task 7.
- **Row 1 rejects itself (self-reject).** Must reset `is_sent = false` (so "ส่งอนุมัติ" can be clicked again after the data-entry user fixes something) and must leave `evaluate_form.is_active` untouched (the record was never finalized). Covered in Task 8.
- **Central-review actions called on a record that hasn't finished the 2-level chain.** `return_for_edit`/`mark_reviewed` on an `evaluate_form` where `is_approve != true` must 400, not silently log a status row for a record still mid-approval. Covered in Task 9.
---
## Task 1: Confirm the next free `request_approve_type` code
**Files:** none (verification-only task; its output, `"09"`, is a literal substituted into every later task's code — do not leave `<code>` placeholders anywhere below).
**Interfaces:**
- Produces: the literal string `"09"`, used as `request_approve_type` in Tasks 5-8.
- [ ] **Step 1: Re-run the live check**
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT DISTINCT request_approve_type, count(*) FROM "BUDGET".approve_state GROUP BY 1 ORDER BY 1;')
for row in cur.fetchall():
print(row)
conn.close()
EOF
```
Expected: rows for `01,02,03,04,05,07,08` (no `06` row is expected — it's reserved in code for invest_construct self-service but has zero live rows — and no `09` row should exist yet). If a `09` row now exists, stop and pick `"10"` instead, updating every later task's literal accordingly.
- [ ] **Step 2: Confirm no code beyond what Step 1 shows is reserved in source**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
grep -rn 'request_approve_type\s*==\s*"[0-9]*"\|request_approve_type\s*=\s*"[0-9]*"\|approveTypeCode\s*=\s*"[0-9]*"' --include="*.cs" . | grep -v .worktrees | sort -u
```
Expected: only codes `"03","04","05","06","07","08"` appear (plus the default-param `"04"`). If `"09"` appears anywhere already, someone else claimed it — pick the next free code instead and update this plan's Global Constraints line and every task below before proceeding.
- [ ] **Step 3: No commit for this task** (verification only — proceed to Task 2 using `"09"`)
---
## Task 2: `evaluate_form_uid` column on `approve_state`/`remark_history`
**Files:**
- Modify: `Modules/Setting/Databases/Models/approve_state.cs`
- Modify: `Modules/Requests/Databases/Models/remark_history.cs`
- Modify: `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`
- Modify: `Modules/Setting/Databases/Db.cs` (wire `v_approve_state.evaluate_form` ↔ `evaluate_form.approve_states`)
- Modify: `Modules/Requests/Databases/Db.cs` (wire `v_remark_history.evaluate_form` ↔ `evaluate_form.remark_historys`)
- Create: `Migrations/<timestamp>_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs` (via `dotnet ef migrations add`)
- Create: `Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql`
- Modify: `Startup.cs` (register the new raw-SQL migration)
**Interfaces:**
- Produces: `approve_state.evaluate_form_uid` (nullable `Guid?`) and `remark_history.evaluate_form_uid` (nullable `Guid?`), both queryable through `v_approve_state`/`v_remark_history`; `evaluate_form.approve_states : List<v_approve_state>` and `evaluate_form.remark_historys : List<v_remark_history>` reverse-nav collections, mirroring exactly how `request_expense.approve_states`/`.remark_historys` are wired (confirmed by reading `Modules/Setting/Databases/Db.cs:316-326` and `Modules/Requests/Databases/Db.cs:91-95` — the `request_expense`/`invest_asset`/`invest_construct` precedent).
- [ ] **Step 1: Add the property to `approve_state`**
Edit `Modules/Setting/Databases/Models/approve_state.cs`, in the `approve_state` base class (after `request_expense_uid`):
```csharp
public Guid? request_expense_uid { get; set; }
public Guid? evaluate_form_uid { get; set; }
```
Also add the nav property to `v_approve_state`:
```csharp
public class v_approve_state : approve_state
{
[JsonIgnore]
public v_invest_asset invest_asset { get; set; }
[JsonIgnore]
public v_invest_construct invest_construct { get; set; }
[JsonIgnore]
public v_request_expense request_expense { get; set; }
[JsonIgnore]
public v_evaluate_form evaluate_form { get; set; }
}
```
(Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block for `v_evaluate_form` to resolve.)
- [ ] **Step 2: Add the property to `remark_history`**
Edit `Modules/Requests/Databases/Models/remark_history.cs`, in the `remark_history` base class (after `request_expense_uid`):
```csharp
public Guid? request_expense_uid { get; set; }
public Guid? evaluate_form_uid { get; set; }
```
Also add the reverse nav to `v_remark_history` (mirroring `v_remark_history.request_expense` a few lines above it):
```csharp
[JsonIgnore] public v_request_expense request_expense { get; set; }
[JsonIgnore] public v_evaluate_form evaluate_form { get; set; }
```
(Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block.)
- [ ] **Step 3: Add the reverse-nav collections to `evaluate_form` and wire them in the two `Db.cs` files**
Edit `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`, add to the `evaluate_form` base class:
```csharp
[Include]
public Guid? evaluate_form_uid { get; set; }
```
(that line already exists — do not duplicate it; instead add these two new lines near the other `[Include]` collections, e.g. right after `evaluate_time_period_uid`):
```csharp
[Include]
public List<v_approve_state> approve_states { get; set; }
[Include]
public List<v_remark_history> remark_historys { get; set; }
```
Add `using rmutr_budget_api.Modules.General.Databases.Models;` and `using rmutr_budget_api.Modules.Request.Databases.Models;` to this file's `using` block for `v_approve_state`/`v_remark_history` to resolve.
Edit `Modules/Setting/Databases/Db.cs`, add immediately after the existing `v_approve_state.request_expense` wiring (after line 326, `.OnDelete(DeleteBehavior.Cascade);`):
```csharp
builder.Entity<v_approve_state>().HasOne(p => p.evaluate_form).WithMany(p => p.approve_states)
.HasForeignKey(p => p.evaluate_form_uid)
.OnDelete(DeleteBehavior.Cascade);
```
(Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block if not already present.)
Edit `Modules/Requests/Databases/Db.cs`, add immediately after the existing `v_remark_history.request_expense` wiring (after the block ending `.OnDelete(DeleteBehavior.Cascade);` around line 94):
```csharp
builder.Entity<t_remark_history>().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys)
.HasForeignKey(p => p.evaluate_form_uid)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<v_remark_history>().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys)
.HasForeignKey(p => p.evaluate_form_uid)
.OnDelete(DeleteBehavior.Cascade);
```
Note `t_remark_history` also needs an `evaluate_form` nav property for the first of these two calls to compile — add `[JsonIgnore] public t_evaluate_form evaluate_form { get; set; }` to the `t_remark_history` class in `remark_history.cs` (next to its other `[JsonIgnore] public t_request_expense request_expense { get; set; }` line), and add the matching `using` for `t_evaluate_form`.
- [ ] **Step 4: Generate the EF Core migration**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet ef migrations add AddEvaluateFormUidToApproveStateAndRemarkHistory
```
Open the generated `Migrations/<timestamp>_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs` and confirm its `Up()` contains exactly two `migrationBuilder.AddColumn<Guid>(name: "evaluate_form_uid", schema: "BUDGET", table: "approve_state"/"remark_history", type: "uuid", nullable: true)` calls (mirroring the shape of `20260820041930_AddRoundNoToApproveStateAndRemarkHistory.cs`) — if `dotnet ef` also picked up unrelated model drift, stop and investigate before proceeding (do not let an unrelated migration ship inside this task's commit).
- [ ] **Step 5: Write the view-refresh raw SQL migration**
Create `Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql`:
```sql
-- Migration: expose evaluate_form_uid (added to approve_state/remark_history base tables by
-- AddEvaluateFormUidToApproveStateAndRemarkHistory) through their matching views.
-- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET).
-- Idempotent: CREATE OR REPLACE VIEW is safe to run on every startup — appending a
-- column at the end of the SELECT list does not require dropping the view first.
CREATE OR REPLACE VIEW "{0}".v_approve_state AS
SELECT approve_state.approve_state_uid,
approve_state.status_id,
approve_state.created_by,
approve_state.created_datetime,
approve_state.updated_by,
approve_state.updated_datetime,
approve_state.owner_agency_uid,
approve_state.reference_uid,
approve_state.row_order,
approve_state.is_approve,
approve_state.is_state,
approve_state.approve_date,
approve_state.personnel_uid,
approve_state.display_name_th,
approve_state.request_approve_type,
approve_state.position_name_th,
approve_state.invest_asset_uid,
approve_state.invest_construct_uid,
approve_state.request_expense_uid,
approve_state.round_no,
approve_state.evaluate_form_uid
FROM "{0}".approve_state;
CREATE OR REPLACE VIEW "{0}".v_remark_history AS
SELECT remark_history.remark_history_uid,
remark_history.status_id,
remark_history.created_by,
remark_history.created_datetime,
remark_history.updated_by,
remark_history.updated_datetime,
remark_history.owner_agency_uid,
remark_history.request_budget_uid,
remark_history.remark_history_code,
remark_history.remark_history_short_name_en,
remark_history.remark_history_short_name_th,
remark_history.remark_history_name_th,
remark_history.remark_history_name_en,
remark_history.remark_history_image,
remark_history.updated_date,
remark_history.updated_name,
remark_history.request_budget_income_uid,
remark_history.budget_progress_uid,
remark_history.invest_asset_request_information_uid,
remark_history.invest_asset_request_uid,
remark_history.invest_asset_uid,
remark_history.invest_construct_request_information_uid,
remark_history.invest_construct_request_uid,
remark_history.invest_construct_uid,
remark_history.request_budget_strategic_uid,
remark_history.request_expense_uid,
remark_history.round_no,
remark_history.evaluate_form_uid
FROM "{0}".remark_history;
```
- [ ] **Step 6: Register the new raw-SQL migration in `Startup.cs`**
Find the line `ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_round_no_to_views.sql");` and add immediately after it:
```csharp
ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql");
```
- [ ] **Step 7: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
ps aux | grep -E "dotnet build|dotnet publish" | grep -v grep # confirm nothing else is building first
dotnet build rmutr-budget-api.csproj
```
Expected: `0 Error(s)`.
- [ ] **Step 8: Apply the migration and verify against the live DB**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet run --urls http://localhost:5000 &
sleep 25 # give Database.Migrate()/ApplyRawSqlMigration a chance to run on startup
kill %1
```
Then verify:
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT column_name FROM information_schema.columns
WHERE table_schema='BUDGET' AND table_name IN ('approve_state','remark_history')
AND column_name='evaluate_form_uid';""")
print(cur.fetchall())
cur.execute("""SELECT column_name FROM information_schema.columns
WHERE table_schema='BUDGET' AND table_name IN ('v_approve_state','v_remark_history')
AND column_name='evaluate_form_uid';""")
print(cur.fetchall())
conn.close()
EOF
```
Expected: both queries return two rows each (one per table/view).
- [ ] **Step 9: Commit**
```bash
git add Modules/Setting/Databases/Models/approve_state.cs Modules/Requests/Databases/Models/remark_history.cs Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs Modules/Setting/Databases/Db.cs Modules/Requests/Databases/Db.cs Migrations/*AddEvaluateFormUidToApproveStateAndRemarkHistory* Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql Startup.cs
git commit -m "feat(evaluate_form): add evaluate_form_uid FK + approve_states/remark_historys nav to approve_state/remark_history"
```
---
## Task 3: `is_sent`/`is_approve`/`current_approve_name` on `evaluate_form`
**Files:**
- Modify: `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`
- Create: `Migrations/<timestamp>_AddApprovalFieldsToEvaluateForm.cs` (via `dotnet ef migrations add`)
- Create: `Migrations/add_approval_fields_to_evaluate_form_view.sql`
- Modify: `Startup.cs`
**Interfaces:**
- Consumes: none new.
- Produces: `evaluate_form.is_sent : bool?`, `evaluate_form.is_approve : bool?`, `evaluate_form.current_approve_name : string`, all queryable through `v_evaluate_form`.
- [ ] **Step 1: Add the properties**
Edit `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`, in the `evaluate_form` base class:
```csharp
public DateTime? active_date { get; set; }
public bool? is_active { get; set; }
public bool? is_sent { get; set; }
public bool? is_approve { get; set; }
public string current_approve_name { get; set; }
```
- [ ] **Step 2: Generate the EF Core migration**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet ef migrations add AddApprovalFieldsToEvaluateForm
```
Confirm the generated `Up()` adds `is_sent boolean` (nullable), `is_approve boolean` (nullable), `current_approve_name character varying`/`text` (nullable) to `evaluate_form` in schema `BUDGET` — and nothing else unrelated.
- [ ] **Step 3: Write the view-refresh raw SQL migration**
Create `Migrations/add_approval_fields_to_evaluate_form_view.sql` (base SELECT list captured live from production on 2026-09-24 via `pg_get_viewdef('"BUDGET".v_evaluate_form'::regclass, true)`, with the three new columns appended):
```sql
-- Migration: expose is_sent/is_approve/current_approve_name (added to evaluate_form by
-- AddApprovalFieldsToEvaluateForm) through v_evaluate_form.
-- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET).
-- Idempotent: CREATE OR REPLACE VIEW is safe to run on every startup.
CREATE OR REPLACE VIEW "{0}".v_evaluate_form AS
SELECT evaluate_form.evaluate_form_uid,
evaluate_form.status_id,
evaluate_form.created_by,
evaluate_form.created_datetime,
evaluate_form.updated_by,
evaluate_form.updated_datetime,
evaluate_form.owner_agency_uid,
evaluate_form.budget_year_uid,
evaluate_form.evaluate_time_period_uid,
evaluate_form.request_date,
evaluate_form.is_active,
evaluate_form.active_date,
evaluate_form.is_success,
budget_year.budget_year_name_th,
evaluate_time_period.evaluate_time_period_name_th,
evaluate_indicator.evaluate_indicator_name_th,
evaluate_time_period.end_date,
evaluate_form.is_sent,
evaluate_form.is_approve,
evaluate_form.current_approve_name
FROM "{0}".evaluate_form
LEFT JOIN "{0}".evaluate_time_period ON evaluate_time_period.evaluate_time_period_uid = evaluate_form.evaluate_time_period_uid
LEFT JOIN "{0}".evaluate_time ON evaluate_time.evaluate_time_uid = evaluate_time_period.evaluate_time_uid
LEFT JOIN "{0}".budget_year ON budget_year.budget_year_uid = evaluate_form.budget_year_uid
LEFT JOIN "{0}".evaluate_indicator ON evaluate_indicator.evaluate_indicator_uid = evaluate_time.evaluate_indicator_uid;
```
- [ ] **Step 4: Register in `Startup.cs`**
Add immediately after the line added in Task 2 Step 5:
```csharp
ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_approval_fields_to_evaluate_form_view.sql");
```
- [ ] **Step 5: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj
```
Expected: `0 Error(s)`.
- [ ] **Step 6: Apply and verify**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet run --urls http://localhost:5000 &
sleep 25
kill %1
```
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT column_name FROM information_schema.columns
WHERE table_schema='BUDGET' AND table_name='evaluate_form'
AND column_name IN ('is_sent','is_approve','current_approve_name') ORDER BY 1;""")
print(cur.fetchall())
cur.execute("""SELECT pg_get_viewdef('"BUDGET".v_evaluate_form'::regclass, true);""")
print(cur.fetchone()[0])
conn.close()
EOF
```
Expected: three rows in the first query; the view definition in the second includes `is_sent`, `is_approve`, `current_approve_name`.
- [ ] **Step 7: Commit**
```bash
git add Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs Migrations/*AddApprovalFieldsToEvaluateForm* Migrations/add_approval_fields_to_evaluate_form_view.sql Startup.cs
git commit -m "feat(evaluate_form): add is_sent/is_approve/current_approve_name status fields"
```
---
## Task 4: `evaluate_form_review` table (central-review log)
**Files:**
- Create: `Migrations/add_evaluate_form_review.sql`
- Create: `Modules/EvaluateFormReview/Databases/Models/evaluate_form_review.cs`
- Create: `Modules/EvaluateFormReview/Databases/Db.cs`
- Modify: `Databases/Db.cs`
- Modify: `Startup.cs`
**Interfaces:**
- Produces: `t_evaluate_form_review` DbSet, table `"BUDGET".evaluate_form_review` with columns `evaluate_form_review_uid, evaluate_form_uid, review_status_id (1=รอตรวจสอบ, 2=ส่งแก้ไข, 3=ตรวจสอบแล้ว), remark`.
- [ ] **Step 1: Raw SQL migration**
Create `Migrations/add_evaluate_form_review.sql` (mirrors `add_request_budget_review.sql` exactly, entity swapped):
```sql
-- Migration: สร้างตาราง evaluate_form_review — เก็บ log การตรวจสอบส่วนกลางของ evaluate_form
-- (evaluate-follow) หลังเชนอนุมัติ 2 ลำดับผ่านครบ (request_approve_type = "09")
-- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET).
-- Idempotent: safe to run on every startup.
CREATE TABLE IF NOT EXISTS "{0}".evaluate_form_review (
evaluate_form_review_uid uuid PRIMARY KEY,
evaluate_form_uid uuid,
review_status_id smallint NOT NULL,
remark text,
status_id smallint,
created_by text,
created_datetime timestamptz,
updated_by text,
updated_datetime timestamptz,
owner_agency_uid uuid
);
CREATE INDEX IF NOT EXISTS ix_evaluate_form_review_evaluate_form_uid
ON "{0}".evaluate_form_review (evaluate_form_uid);
```
- [ ] **Step 2: Model**
Create `Modules/EvaluateFormReview/Databases/Models/evaluate_form_review.cs`:
```csharp
using System;
using System.ComponentModel.DataAnnotations;
using SeventyOneDev.Utilities;
namespace rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models
{
public class evaluate_form_review : base_table
{
[Key] public Guid? evaluate_form_review_uid { get; set; }
public Guid? evaluate_form_uid { get; set; }
/// <summary>1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — mirrors request_budget_review,
/// see docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §3.3</summary>
public short review_status_id { get; set; }
public string remark { get; set; }
}
public class t_evaluate_form_review : evaluate_form_review
{
}
}
```
- [ ] **Step 3: DbContext registration**
Create `Modules/EvaluateFormReview/Databases/Db.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;
namespace SeventyOneDev.Utilities
{
public partial class Db : DbContext
{
public DbSet<t_evaluate_form_review> t_evaluate_form_review { get; set; }
private void OnEvaluateFormReviewModelCreating(ModelBuilder builder, string schema)
{
builder.Entity<t_evaluate_form_review>().ToTable("evaluate_form_review", schema);
}
}
}
```
Edit `Databases/Db.cs`, add after the `OnRequestBudgetReviewModelCreating(builder,schema);` line:
```csharp
OnRequestBudgetReviewModelCreating(builder,schema);
OnEvaluateFormReviewModelCreating(builder,schema);
```
- [ ] **Step 4: Register the raw SQL migration in `Startup.cs`**
Add after the line added in Task 3 Step 4:
```csharp
ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_evaluate_form_review.sql");
```
- [ ] **Step 5: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj
```
Expected: `0 Error(s)`. (No `dotnet ef migrations add` needed here — this table is created by the raw SQL file directly, not EF code-first, matching `request_budget_review`'s precedent of not having a Designer.cs migration.)
- [ ] **Step 6: Apply and verify**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet run --urls http://localhost:5000 &
sleep 25
kill %1
```
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT table_name FROM information_schema.tables
WHERE table_schema='BUDGET' AND table_name='evaluate_form_review';""")
print(cur.fetchall())
conn.close()
EOF
```
Expected: one row.
- [ ] **Step 7: Commit**
```bash
git add Migrations/add_evaluate_form_review.sql Modules/EvaluateFormReview Databases/Db.cs Startup.cs
git commit -m "feat(evaluate_form_review): add central-review log table"
```
---
## Task 5: `SetCustomApprovers` for `evaluate_form`
**Files:**
- Modify: `Modules/Evaluate/Services/EvaluateForm.cs`
- Modify: `Modules/Evaluate/Controllers/EvaluateForm.cs`
**Interfaces:**
- Consumes: `t_approve_state`, `t_evaluate_form` (via `_context`, already injected in `EvaluateFormService`).
- Produces: `Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)` on `EvaluateFormService`, and `POST /api/evaluate/evaluate_form/set_approvers/{uid}`.
- `ApproverInput` (`personnel_uid: Guid?`, `display_name_th: string`) already exists in `rmutr_budget_api.Modules.RequestExpense.Services` — reference it via `using rmutr_budget_api.Modules.RequestExpense.Services;` rather than redefining it.
- [ ] **Step 1: Add the method to `EvaluateFormService`**
Edit `Modules/Evaluate/Services/EvaluateForm.cs`, add `using rmutr_budget_api.Modules.RequestExpense.Services;` and `using rmutr_budget_api.Modules.General.Databases.Models;` to the `using` block, then add this method to the class body:
```csharp
/// <summary>
/// ให้ผู้กรอกข้อมูลกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" — mirror
/// RequestExpenseService.SetCustomApprovers ทุกจุด (code "09")
/// </summary>
public async Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid,
List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
{
var entity = await _context.t_evaluate_form
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
if (entity == null) return (false, "ไม่พบรายการ");
if (entity.is_sent != null || entity.is_approve != null)
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
var existing = await _context.t_approve_state
.Where(c => c.evaluate_form_uid == evaluateFormUid).ToListAsync();
if (existing.Any(s => s.is_approve == true))
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
approvers = (approvers ?? new List<ApproverInput>())
.Where(a => !string.IsNullOrWhiteSpace(a?.display_name_th)).ToList();
if (!approvers.Any()) return (false, "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน");
if (existing.Any())
{
_context.t_approve_state.RemoveRange(existing);
await _context.SaveChangesAsync(claimsIdentity);
}
var rows = approvers.Select((a, idx) => new t_approve_state
{
approve_state_uid = Guid.NewGuid(),
evaluate_form_uid = evaluateFormUid,
personnel_uid = a.personnel_uid,
display_name_th = a.display_name_th,
row_order = idx + 1,
is_state = idx == 0,
request_approve_type = "09",
}).ToList();
await _context.t_approve_state.AddRangeAsync(rows);
entity.current_approve_name = rows.First().display_name_th;
_context.t_evaluate_form.Update(entity);
await _context.SaveChangesAsync(claimsIdentity);
return (true, null);
}
```
- [ ] **Step 2: Add the endpoint**
Edit `Modules/Evaluate/Controllers/EvaluateForm.cs`, add after `FinalActiveAgencyEvaluate` (before `SendBackEvaluate`):
```csharp
[Authorize]
[HttpPost("set_approvers/{evaluate_form_uid:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> SetApprovers([FromRoute] Guid evaluate_form_uid,
[FromBody] List<rmutr_budget_api.Modules.RequestExpense.Services.ApproverInput> approvers)
{
var result = await _entityUidService.SetCustomApprovers(evaluate_form_uid, approvers, User.Identity as ClaimsIdentity);
if (!result.ok) return BadRequest(new { description = result.error });
return Ok();
}
```
- [ ] **Step 3: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj
```
Expected: `0 Error(s)`.
- [ ] **Step 4: Run locally and verify with synthetic data + curl**
Start the API: `cd /Users/nut.looknut/Project/rmutr/rmutr-api && dotnet run --urls http://localhost:5000` (leave running in this shell for Tasks 5-9's manual verification; obtain a bearer token by logging into the running rmutr-web app locally against this API and copying the `Authorization` header value from any XHR request, or from the OIDC token in browser storage — same convention as `docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md`).
Create a synthetic `evaluate_form` row via SQL (adjust `budget_year_uid`/`evaluate_time_period_uid` to real existing UIDs from your DB — query `SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;` and `SELECT evaluate_time_period_uid FROM "BUDGET".evaluate_time_period LIMIT 1;` first if needed):
```bash
python3 <<'EOF'
import psycopg2, uuid
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;')
byu = cur.fetchone()[0]
form_uid = uuid.uuid4()
cur.execute("""INSERT INTO "BUDGET".evaluate_form
(evaluate_form_uid, budget_year_uid, request_date, created_by, status_id)
VALUES (%s, %s, now(), 'test.verify', 1);""", (str(form_uid), str(byu)))
conn.commit()
print("evaluate_form_uid:", form_uid)
conn.close()
EOF
```
```bash
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/set_approvers/<form_uid-from-above>" \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"},{"display_name_th":"ทดสอบ รองอธิการบดี"}]'
```
Expected: `200 OK`.
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT row_order, is_state, display_name_th, request_approve_type
FROM "BUDGET".approve_state WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<form_uid-from-above>",))
print(cur.fetchall())
conn.close()
EOF
```
Expected: two rows, `row_order 1` with `is_state=True`, `row_order 2` with `is_state=False`, both `request_approve_type='09'`. **Do not delete this test data yet** — Tasks 6-9 reuse it.
- [ ] **Step 5: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
git add Modules/Evaluate/Services/EvaluateForm.cs Modules/Evaluate/Controllers/EvaluateForm.cs
git commit -m "feat(evaluate_form): add SetCustomApprovers + set_approvers endpoint"
```
---
## Task 6: `SendApprove` for `evaluate_form`
**Files:**
- Modify: `Modules/Evaluate/Services/EvaluateForm.cs`
- Modify: `Modules/Evaluate/Controllers/EvaluateForm.cs`
**Interfaces:**
- Produces: `Task<(bool ok, string error)> SendApprove(Guid? evaluateFormUid, ClaimsIdentity claimsIdentity)` on `EvaluateFormService`, and `POST /api/evaluate/evaluate_form/send_approve/{uid}`.
- [ ] **Step 1: Add the method**
Add to `Modules/Evaluate/Services/EvaluateForm.cs`:
```csharp
/// <summary>
/// "ส่งอนุมัติ" — ใครก็ได้กดได้ แค่ตั้ง is_sent=true ไม่แตะ approve_state — mirror
/// RequestExpenseService.SendApprove2
/// </summary>
public async Task<(bool ok, string error)> SendApprove(Guid? evaluateFormUid, ClaimsIdentity claimsIdentity)
{
var entity = await _context.t_evaluate_form
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
if (entity == null) return (false, "ไม่พบรายการ");
if (entity.is_approve == true) return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");
var isPristine = entity.is_sent == null && entity.is_approve == null;
var isRejectedReadyToResend = entity.is_sent == false && entity.is_approve == false;
if (!isPristine && !isRejectedReadyToResend)
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");
var pending = await _context.t_approve_state.FirstOrDefaultAsync(c =>
c.evaluate_form_uid == evaluateFormUid && c.is_state == true);
if (pending == null || pending.row_order != 1)
return (false, "ยังไม่พร้อมส่งอนุมัติ กรุณากำหนดผู้อนุมัติก่อน");
entity.is_sent = true;
entity.is_approve = null;
_context.t_evaluate_form.Update(entity);
await _context.SaveChangesAsync(claimsIdentity);
return (true, null);
}
```
- [ ] **Step 2: Add the endpoint**
Add to `Modules/Evaluate/Controllers/EvaluateForm.cs`, after `SetApprovers`:
```csharp
[Authorize]
[HttpPost("send_approve/{evaluate_form_uid:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> SendApprove([FromRoute] Guid evaluate_form_uid)
{
var result = await _entityUidService.SendApprove(evaluate_form_uid, User.Identity as ClaimsIdentity);
if (!result.ok) return BadRequest(new { description = result.error });
return Ok();
}
```
- [ ] **Step 3: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj
```
Expected: `0 Error(s)`.
- [ ] **Step 4: Verify (reuse Task 5's test `evaluate_form`)**
Restart `dotnet run` if it isn't still running, then:
```bash
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<form_uid>" \
-H "Authorization: Bearer <token>"
```
Expected: `200 OK`.
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<form_uid>",))
print(cur.fetchone())
conn.close()
EOF
```
Expected: `(True, None)`.
- [ ] **Step 5: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
git add Modules/Evaluate/Services/EvaluateForm.cs Modules/Evaluate/Controllers/EvaluateForm.cs
git commit -m "feat(evaluate_form): add SendApprove + send_approve endpoint"
```
---
## Task 7: Extend `ApproveGetEntity` (approve) with the `evaluate_form` branch
**Files:**
- Modify: `Modules/Setting/Controllers/ApproveState.cs`
**Interfaces:**
- Consumes: `_db.t_evaluate_form`, `_db.t_approve_state`, `_db.t_evaluate_form_review` (add `using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;`).
- [ ] **Step 1: Add the branch**
Edit `ApproveGetEntity` in `Modules/Setting/Controllers/ApproveState.cs`. First, add `evaluate_form` to the initial lookup block:
```csharp
var invest_asset = await _db.t_invest_asset.FirstOrDefaultAsync(c => c.invest_asset_uid == uid);
var invest_construct = await _db.t_invest_construct.FirstOrDefaultAsync(c => c.invest_construct_uid == uid);
var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
var evaluate_form = await _db.t_evaluate_form.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid);
if (invest_asset == null && invest_construct == null && request_expense == null && evaluate_form == null)
{
return BadRequest("Error, Not Found Approve State.");
}
```
Then add a new `if (evaluate_form != null)` block, placed after the existing `if (request_expense != null) { ... }` block and before `return Ok(getResult);`:
```csharp
if (evaluate_form != null)
{
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
c.evaluate_form_uid == uid && c.is_state == true);
if (getResult != null)
{
// ประเมินผล (evaluate_form) แบบผู้กรอกข้อมูลเลือกผู้อนุมัติเอง (code "09") — เช็คสิทธิ์
// ทุก row_order ตั้งแต่วันแรก (ไม่ใช่แค่ row_order==1) ตาม fix ที่ทำกับ request_expense
// ไปแล้วเมื่อ 2026-09-08 — ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §6
if (getResult.row_order == 1 && evaluate_form.is_sent != true)
{
return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
}
var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value;
if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th)
{
return StatusCode(StatusCodes.Status403Forbidden,
new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติในลำดับนี้เท่านั้น)" });
}
getResult.is_state = null;
getResult.is_approve = true;
evaluate_form.current_approve_name = null;
_db.t_approve_state.Update(getResult);
_db.t_evaluate_form.Update(evaluate_form);
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
var check_all_state = await _db.t_approve_state
.Where(c => c.evaluate_form_uid == uid && c.is_approve != true).ToListAsync();
if (!check_all_state.Any())
{
evaluate_form.is_approve = true;
evaluate_form.is_active = true;
evaluate_form.active_date = DateTime.UtcNow.Date;
_db.t_evaluate_form.Update(evaluate_form);
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
await _db.t_evaluate_form_review.AddAsync(new t_evaluate_form_review
{
evaluate_form_review_uid = Guid.NewGuid(),
evaluate_form_uid = uid,
review_status_id = 1,
});
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
}
var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
c.evaluate_form_uid == uid && c.row_order == getResult.row_order + 1);
if (check_getResult != null)
{
check_getResult.is_state = true;
evaluate_form.current_approve_name = check_getResult.display_name_th;
_db.t_evaluate_form.Update(evaluate_form);
_db.t_approve_state.Update(check_getResult);
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
}
}
}
```
- [ ] **Step 2: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj
```
Expected: `0 Error(s)`.
- [ ] **Step 3: Verify the "approve before send" guard (Review Focus item 1)**
Using a **fresh** synthetic `evaluate_form` (repeat Task 5 Step 4's insert + `set_approvers` call, but skip `send_approve`):
```bash
curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/<new_form_uid>" \
-H "Authorization: Bearer <approver-1's-token>"
```
Expected: `400` with `description` containing `ยังไม่ได้ส่งอนุมัติ`.
- [ ] **Step 4: Verify the wrong-person guard (Review Focus item 3)**
Call `send_approve` on the same record, then call `approve` with a token whose `display_name` claim does **not** match `"ทดสอบ หัวหน้าหน่วยงาน"`:
```bash
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<new_form_uid>" -H "Authorization: Bearer <token>"
curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/<new_form_uid>" -H "Authorization: Bearer <someone-else's-token>"
```
Expected: `403`.
- [ ] **Step 5: Verify the single-approver happy path (Review Focus item 2)**
Create a **third** synthetic `evaluate_form`, call `set_approvers` with **only one** approver (`row_order 2` never created), `send_approve`, then `approve` with a token whose `display_name` matches that one approver's `display_name_th`:
```bash
python3 <<'EOF'
import psycopg2, uuid
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;')
byu = cur.fetchone()[0]
form_uid = uuid.uuid4()
cur.execute("""INSERT INTO "BUDGET".evaluate_form (evaluate_form_uid, budget_year_uid, request_date, created_by, status_id)
VALUES (%s, %s, now(), 'test.verify', 1);""", (str(form_uid), str(byu)))
conn.commit()
print("single_approver_form_uid:", form_uid)
conn.close()
EOF
```
```bash
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/set_approvers/<single_approver_form_uid>" \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"}]'
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<single_approver_form_uid>" -H "Authorization: Bearer <token>"
curl -s "http://localhost:5000/api/setting/approve_state/approve/<single_approver_form_uid>" -H "Authorization: Bearer <token-matching-หัวหน้าหน่วยงาน>"
```
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT is_approve, is_active FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<single_approver_form_uid>",))
print(cur.fetchone())
cur.execute('SELECT review_status_id FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid = %s;', ("<single_approver_form_uid>",))
print(cur.fetchall())
conn.close()
EOF
```
Expected: `(True, True)`; one `evaluate_form_review` row with `review_status_id=1`.
- [ ] **Step 6: Clean up all synthetic data from Tasks 5-7**
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""DELETE FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid IN
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""")
conn.commit()
conn.close()
EOF
```
(Leave the two-approver form from Task 5 alive if Task 8 still needs it — Task 8 creates its own fresh data instead, so it's safe to delete everything here.)
- [ ] **Step 7: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
git add Modules/Setting/Controllers/ApproveState.cs
git commit -m "feat(approve_state): add evaluate_form branch to approve endpoint"
```
---
## Task 8: Extend `RejectGetEntity` (reject) with the `evaluate_form` branch
**Files:**
- Modify: `Modules/Setting/Controllers/ApproveState.cs`
- [ ] **Step 1: Add the branch**
In `RejectGetEntity`, add `evaluate_form` to the initial lookup block:
```csharp
var request_expense_approve =
await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.is_state == true);
var evaluate_form_approve =
await _db.t_approve_state.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid && c.is_state == true);
if (invest_asset_approve == null && invest_construct_approve == null && request_expense_approve == null && evaluate_form_approve == null)
{
return BadRequest("Error, Not Found Approve State.");
}
```
Then add a new block after the existing `if (request_expense_approve != null) { ... }` block and before `return Ok(getResult);`:
```csharp
if (evaluate_form_approve != null)
{
getResult = evaluate_form_approve;
var result_data_lists = await _db.t_approve_state
.Where(c => c.evaluate_form_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;
}
_db.t_approve_state.UpdateRange(result_data_lists);
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
var evaluate_form = await _db.t_evaluate_form.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid);
if (evaluate_form != null)
{
evaluate_form.current_approve_name = null;
var approve = await _db.t_approve_state
.Where(c => c.evaluate_form_uid == uid && c.is_state == true)
.OrderBy(c => c.row_order).ToListAsync();
if (approve.Count > 0)
{
evaluate_form.current_approve_name = approve.First().display_name_th;
}
evaluate_form.is_approve = false;
if (index_current == 0)
{
// ลำดับ 1 ส่งแก้ไขเอง: ไม่มีลำดับก่อนหน้าให้ตีกลับ ต้องรีเซ็ต is_sent เพื่อให้กลับไปเป็น
// สถานะ "ยังไม่ได้ส่งอนุมัติ" ให้ผู้กรอกข้อมูล (ใครก็ได้) กลับมาส่งใหม่ได้อีกครั้งหลังแก้ไข
evaluate_form.is_sent = false;
}
_db.t_evaluate_form.Update(evaluate_form);
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
}
}
```
- [ ] **Step 2: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj
```
Expected: `0 Error(s)`.
- [ ] **Step 3: Verify self-reject at row 1 (Review Focus item 4)**
Create a fresh two-approver synthetic form (repeat Task 5 Step 4), `send_approve`, then reject as the row-1 approver:
```bash
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<form_uid>" -H "Authorization: Bearer <token>"
curl -s "http://localhost:5000/api/setting/approve_state/reject/<form_uid>" -H "Authorization: Bearer <token>"
```
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT is_sent, is_approve, is_active FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<form_uid>",))
print(cur.fetchone())
conn.close()
EOF
```
Expected: `(False, False, None)` — `is_sent` reset to `False`, `is_active` untouched (still `None`, never finalized).
- [ ] **Step 4: Verify row-2 reject steps back to row 1 (not straight back to the creator)**
Create another fresh two-approver form, run it through `set_approvers` → `send_approve` → `approve` as the row-1 approver (advances the queue to row 2), then reject as the row-2 approver:
```bash
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<row2_reject_form_uid>" -H "Authorization: Bearer <token>"
curl -s "http://localhost:5000/api/setting/approve_state/approve/<row2_reject_form_uid>" -H "Authorization: Bearer <token-matching-หัวหน้าหน่วยงาน>"
curl -s "http://localhost:5000/api/setting/approve_state/reject/<row2_reject_form_uid>" -H "Authorization: Bearer <token-matching-รองอธิการบดี>"
```
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT row_order, is_state, is_approve FROM "BUDGET".approve_state
WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<row2_reject_form_uid>",))
print(cur.fetchall())
cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<row2_reject_form_uid>",))
print(cur.fetchone())
conn.close()
EOF
```
Expected: `row_order 1` has `is_state=True, is_approve=None` (reopened), `row_order 2` has `is_state=None, is_approve=None` (not straight back to row 1's original never-approved state — it was `is_approve=True` before this reject and must now be cleared); `evaluate_form.is_sent` stays `True` (unlike the row-1 self-reject case in Step 3), `is_approve=False`.
- [ ] **Step 5: Clean up**
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""")
conn.commit()
conn.close()
EOF
```
- [ ] **Step 6: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
git add Modules/Setting/Controllers/ApproveState.cs
git commit -m "feat(approve_state): add evaluate_form branch to reject endpoint"
```
---
## Task 9: `EvaluateFormReview` controller + service (central-review stage)
**Files:**
- Create: `Modules/EvaluateFormReview/Services/EvaluateFormReview.cs`
- Create: `Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs`
**Interfaces:**
- Produces: `GET /api/evaluate_form_review/status/{evaluate_form_uid}`, `GET /api/evaluate_form_review/dashboard`, `POST /api/evaluate_form_review/return_for_edit/{evaluate_form_uid}` (body `{remark}`), `POST /api/evaluate_form_review/mark_reviewed/{evaluate_form_uid}`.
- [ ] **Step 1: Service**
Create `Modules/EvaluateFormReview/Services/EvaluateFormReview.cs` (mirrors `RequestBudgetReviewService` exactly, entity swapped; `evaluate_form` has no `faculty_uid`/`budget_location_uid` denormalized fields to join for a dashboard item, so the dashboard item is intentionally smaller than `request_budget_review_dashboard_item`):
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;
using rmutr_budget_api.Modules.Evaluate.Databases.Models;
using SeventyOneDev.Utilities;
namespace rmutr_budget_api.Modules.EvaluateFormReview.Services
{
public class evaluate_form_review_history_item
{
public DateTime? datetime { get; set; }
public string by { get; set; }
public string detail { get; set; }
}
public class evaluate_form_review_status_response
{
public Guid? evaluate_form_uid { get; set; }
public string current_status { get; set; }
public List<evaluate_form_review_history_item> history { get; set; } = new();
}
public class evaluate_form_review_dashboard_item
{
public Guid? evaluate_form_uid { get; set; }
public Guid? budget_year_uid { get; set; }
public string budget_year_name_th { get; set; }
public DateTime? updated_datetime { get; set; }
}
public class evaluate_form_review_dashboard
{
public List<evaluate_form_review_dashboard_item> pending { get; set; } = new();
public List<evaluate_form_review_dashboard_item> returned { get; set; } = new();
public List<evaluate_form_review_dashboard_item> reviewed { get; set; } = new();
}
public class EvaluateFormReviewService
{
private readonly Db _context;
public EvaluateFormReviewService(Db context)
{
_context = context;
}
private string ComputeStatus(t_evaluate_form entity, t_evaluate_form_review latestLog)
{
if (entity.is_sent == false && entity.is_approve == false) return "returned";
if (entity.is_approve == true)
{
if (latestLog != null && latestLog.review_status_id == 3) return "reviewed";
return "pending";
}
return "not_ready";
}
public async Task<evaluate_form_review_status_response> GetStatus(Guid evaluateFormUid)
{
var entity = await _context.t_evaluate_form
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
if (entity == null) return null;
var logs = await _context.t_evaluate_form_review.AsNoTracking()
.Where(c => c.evaluate_form_uid == evaluateFormUid)
.OrderByDescending(c => c.created_datetime)
.ToListAsync();
var latestLog = logs.FirstOrDefault();
var remarks = await _context.t_remark_history.AsNoTracking()
.Where(c => c.evaluate_form_uid == evaluateFormUid)
.OrderByDescending(c => c.updated_date)
.ToListAsync();
var history = remarks
.Select(r => new evaluate_form_review_history_item
{
datetime = r.updated_date,
by = r.updated_name,
detail = r.remark_history_name_th,
})
.OrderByDescending(h => h.datetime)
.ToList();
return new evaluate_form_review_status_response
{
evaluate_form_uid = evaluateFormUid,
current_status = ComputeStatus(entity, latestLog),
history = history,
};
}
public async Task<evaluate_form_review_dashboard> GetDashboard()
{
var result = new evaluate_form_review_dashboard();
var candidates = await _context.v_evaluate_form.AsNoTracking()
.Where(e => e.is_approve == true || (e.is_sent == false && e.is_approve == false))
.ToListAsync();
var candidateIds = candidates.Select(c => c.evaluate_form_uid).ToList();
var allLogs = await _context.t_evaluate_form_review.AsNoTracking()
.Where(r => candidateIds.Contains(r.evaluate_form_uid))
.ToListAsync();
var latestLogByForm = allLogs
.GroupBy(r => r.evaluate_form_uid)
.ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.created_datetime).First());
evaluate_form_review_dashboard_item ToItem(v_evaluate_form e) => new()
{
evaluate_form_uid = e.evaluate_form_uid,
budget_year_uid = e.budget_year_uid,
budget_year_name_th = e.budget_year_name_th,
updated_datetime = e.updated_datetime,
};
foreach (var e in candidates)
{
latestLogByForm.TryGetValue(e.evaluate_form_uid, out var latestLog);
var status = ComputeStatus(new t_evaluate_form
{
is_sent = e.is_sent,
is_approve = e.is_approve,
}, latestLog);
var item = ToItem(e);
if (status == "pending") result.pending.Add(item);
else if (status == "returned") result.returned.Add(item);
else if (status == "reviewed") result.reviewed.Add(item);
}
return result;
}
public async Task<(bool ok, string error)> ReturnForEdit(Guid evaluateFormUid, string remark,
ClaimsIdentity claimsIdentity)
{
var entity = await _context.t_evaluate_form
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
if (entity == null) return (false, "ไม่พบรายการ");
if (entity.is_approve != true)
return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ ไม่สามารถส่งแก้ไขจากการตรวจสอบได้");
if (string.IsNullOrWhiteSpace(remark))
return (false, "กรุณาระบุรายละเอียดที่ส่งแก้ไข");
var lastRow = await _context.t_approve_state
.Where(c => c.evaluate_form_uid == evaluateFormUid)
.OrderByDescending(c => c.row_order)
.FirstOrDefaultAsync();
if (lastRow == null) return (false, "ไม่พบผู้อนุมัติของรายการนี้");
lastRow.is_state = true;
lastRow.is_approve = null;
_context.t_approve_state.Update(lastRow);
entity.is_approve = false;
entity.current_approve_name = lastRow.display_name_th;
_context.t_evaluate_form.Update(entity);
await _context.t_remark_history.AddAsync(new rmutr_budget_api.Modules.Request.Databases.Models.t_remark_history
{
remark_history_uid = Guid.NewGuid(),
evaluate_form_uid = evaluateFormUid,
remark_history_name_th = remark,
updated_date = DateTime.Now,
updated_name = claimsIdentity?.FindFirst("display_name")?.Value,
});
await _context.t_evaluate_form_review.AddAsync(new t_evaluate_form_review
{
evaluate_form_review_uid = Guid.NewGuid(),
evaluate_form_uid = evaluateFormUid,
review_status_id = 2,
remark = remark,
});
await _context.SaveChangesAsync(claimsIdentity);
return (true, null);
}
public async Task<(bool ok, string error)> MarkReviewed(Guid evaluateFormUid, ClaimsIdentity claimsIdentity)
{
var entity = await _context.t_evaluate_form
.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
if (entity == null) return (false, "ไม่พบรายการ");
if (entity.is_approve != true)
return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ");
var latestLog = await _context.t_evaluate_form_review.AsNoTracking()
.Where(c => c.evaluate_form_uid == evaluateFormUid)
.OrderByDescending(c => c.created_datetime)
.FirstOrDefaultAsync();
var isPendingWithLog = latestLog != null && latestLog.review_status_id == 1;
var isPendingWithoutLog = latestLog == null && entity.is_approve == true;
if (!isPendingWithLog && !isPendingWithoutLog)
return (false, "รายการนี้ไม่ได้อยู่ในสถานะรอตรวจสอบ");
await _context.t_evaluate_form_review.AddAsync(new t_evaluate_form_review
{
evaluate_form_review_uid = Guid.NewGuid(),
evaluate_form_uid = evaluateFormUid,
review_status_id = 3,
});
await _context.SaveChangesAsync(claimsIdentity);
return (true, null);
}
}
}
```
- [ ] **Step 2: Controller**
Create `Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs`:
```csharp
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using rmutr_budget_api.Modules.EvaluateFormReview.Services;
namespace rmutr_budget_api.Modules.EvaluateFormReview.Controllers
{
public class evaluate_form_return_for_edit_input
{
public string remark { get; set; }
}
[Route("api/evaluate_form_review", Name = "evaluate_form_review")]
public class EvaluateFormReviewController : Controller
{
private readonly EvaluateFormReviewService _service;
public EvaluateFormReviewController(EvaluateFormReviewService service)
{
_service = service;
}
[Authorize]
[HttpGet("status/{evaluate_form_uid:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<evaluate_form_review_status_response>> GetStatus([FromRoute] Guid evaluate_form_uid)
{
var result = await _service.GetStatus(evaluate_form_uid);
return result == null ? NotFound() : Ok(result);
}
[Authorize]
[HttpGet("dashboard")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<evaluate_form_review_dashboard>> GetDashboard()
{
return Ok(await _service.GetDashboard());
}
[Authorize]
[HttpPost("return_for_edit/{evaluate_form_uid:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> ReturnForEdit([FromRoute] Guid evaluate_form_uid,
[FromBody] evaluate_form_return_for_edit_input input)
{
var result = await _service.ReturnForEdit(evaluate_form_uid, input?.remark, User.Identity as ClaimsIdentity);
if (!result.ok) return BadRequest(new { description = result.error });
return Ok();
}
[Authorize]
[HttpPost("mark_reviewed/{evaluate_form_uid:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> MarkReviewed([FromRoute] Guid evaluate_form_uid)
{
var result = await _service.MarkReviewed(evaluate_form_uid, User.Identity as ClaimsIdentity);
if (!result.ok) return BadRequest(new { description = result.error });
return Ok();
}
}
}
```
`EvaluateFormReviewService` needs to be registered for DI — check `Startup.cs`'s `ConfigureServices` for how `RequestBudgetReviewService` is registered (likely `services.AddScoped<RequestBudgetReviewService>();` or an assembly-scan) and add the equivalent line for `EvaluateFormReviewService` if registration is explicit rather than scanned.
- [ ] **Step 3: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj
```
Expected: `0 Error(s)`.
- [ ] **Step 4: Verify the not-yet-approved guard (Review Focus item 5)**
Create a fresh `evaluate_form` with **no** approvers configured at all (`is_approve` still `null`):
```bash
curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/<fresh_form_uid>" -H "Authorization: Bearer <token>"
curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/<fresh_form_uid>" \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"remark":"test"}'
```
Expected: both `400`.
- [ ] **Step 5: Verify the full happy path end-to-end**
Reuse (or recreate) a two-approver form, run it through `set_approvers` → `send_approve` → `approve` (row 1) → `approve` (row 2) → confirm `is_approve=true` and an `evaluate_form_review` row with `review_status_id=1` exists (already covered by Task 7 Step 5's assertions) → then:
```bash
curl -s "http://localhost:5000/api/evaluate_form_review/status/<form_uid>" -H "Authorization: Bearer <token>"
```
Expected: `{"current_status":"pending", ...}`.
```bash
curl -s -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/<form_uid>" -H "Authorization: Bearer <token>"
curl -s "http://localhost:5000/api/evaluate_form_review/status/<form_uid>" -H "Authorization: Bearer <token>"
```
Expected: `{"current_status":"reviewed", ...}`.
- [ ] **Step 6: Verify the full `return_for_edit` → re-approve → `mark_reviewed` loop (spec §11 test path 4)**
Run a fresh two-approver form through the full chain to `pending` (repeat Task 7 Step 5's flow with two approvers instead of one), then:
```bash
curl -s -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/<loop_form_uid>" \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"remark":"ทดสอบส่งแก้ไขจากการตรวจสอบ"}'
```
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT row_order, is_state, is_approve FROM "BUDGET".approve_state
WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<loop_form_uid>",))
print(cur.fetchall())
cur.execute('SELECT is_approve, current_approve_name FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<loop_form_uid>",))
print(cur.fetchone())
conn.close()
EOF
```
Expected: `row_order 2` (the last row, "รองอธิการบดี") has `is_state=True, is_approve=None` — reopened, **not** `row_order 1` — and `evaluate_form.is_approve=False`, `current_approve_name` matches `row_order 2`'s name.
Re-approve as the row-2 approver and confirm the loop closes back to `pending`:
```bash
curl -s "http://localhost:5000/api/setting/approve_state/approve/<loop_form_uid>" -H "Authorization: Bearer <token-matching-รองอธิการบดี>"
curl -s "http://localhost:5000/api/evaluate_form_review/status/<loop_form_uid>" -H "Authorization: Bearer <token>"
```
Expected: `{"current_status":"pending", "history":[{"detail":"ทดสอบส่งแก้ไขจากการตรวจสอบ", ...}]}` (a second `evaluate_form_review` row with `review_status_id=1` was inserted; `evaluate_form.is_approve` is `true` again).
- [ ] **Step 7: Clean up all remaining synthetic data**
```bash
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""DELETE FROM "BUDGET".remark_history WHERE evaluate_form_uid IN
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
cur.execute("""DELETE FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid IN
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN
(SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""")
cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""")
conn.commit()
conn.close()
EOF
```
Stop the local `dotnet run` process.
- [ ] **Step 8: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
git add Modules/EvaluateFormReview/Services/EvaluateFormReview.cs Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs Startup.cs
git commit -m "feat(evaluate_form_review): add central-review status/dashboard/return_for_edit/mark_reviewed endpoints"
```
Backend work is complete after this task. Push the branch and merge into `master` per this project's normal flow before starting frontend tasks, so the frontend tasks can build against the real deployed (or at least locally running) API.
---
## Task 10: "กำหนดผู้อนุมัติ" button on evaluate-collect-send
**Files:**
- Modify: `src/app/core/service/evaluate/evaluate-form.service.ts`
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`
**Interfaces:**
- Produces: `EvaluateFormService.setApprovers(id, approvers)`, `EvaluateCollectSendComponent.onSetApprovers()`.
- Consumes: `SetApproversDialogComponent` (`src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component.ts`) — unchanged.
- [ ] **Step 1: Add the service method**
Edit `src/app/core/service/evaluate/evaluate-form.service.ts`:
```typescript
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {BaseService} from '../../base/base-service';
@Injectable({
providedIn: 'root'
})
export class EvaluateFormService extends BaseService {
constructor(
public http: HttpClient
) {
super('/evaluate/evaluate_form', http);
}
setApprovers(id: string, approvers: { personnel_uid: string, display_name_th: string }[]) {
return this.post(`set_approvers/${id}`, approvers);
}
sendApprove(id: string) {
return this.post(`send_approve/${id}`, {});
}
}
```
(Check `BaseService.post()`'s signature in `src/app/core/base/base-service.ts` before writing this — if it doesn't build a URL the same way `RequestExpenseService`'s raw `this.http.post` calls do, use the same `this.http.post` pattern instead, pointed at `${this.fullUrl}/set_approvers/${id}` / `${this.fullUrl}/send_approve/${id}`.)
- [ ] **Step 2: Add the dialog wiring to the component**
Edit `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`, add imports:
```typescript
import {SetApproversDialogComponent} from '../../../../shared/components/set-approvers-dialog/set-approvers-dialog.component';
import Swal from 'sweetalert2';
```
Add the method (near `onSend`):
```typescript
onSetApprovers() {
const existing = (this.dataForm.approve_states || []).slice().sort((a, b) => a.row_order - b.row_order);
const ref = this.dialog.open(SetApproversDialogComponent, {
width: '600px',
disableClose: true,
data: {approvers: existing},
});
ref.afterClosed().subscribe((approvers) => {
if (!approvers) return;
this.evaluateFormService.setApprovers(this.uid, approvers).subscribe(
() => {
Swal.fire({title: 'บันทึกผู้อนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData());
},
(err) => {
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
}
);
});
}
```
This relies on `dataForm.approve_states` and `dataForm.is_sent`/`is_approve` being present on the entity returned by `GET /evaluate/evaluate_form/{uid}` — Task 2 already wired `evaluate_form.approve_states : List<v_approve_state>` (via `[Include]` + the `HasOne`/`WithMany` in `Modules/Setting/Databases/Db.cs`), so no further backend change is needed here.
- [ ] **Step 3: Add the button to the template**
Edit `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`, add before the existing `onSend()` button (around line 306):
```html
<button (click)="onSetApprovers()" *ngIf="isAction !== 'view' && !dataForm.is_sent" mat-raised-button type="button">กำหนดผู้อนุมัติ</button>
```
- [ ] **Step 4: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
ng build
```
Expected: compiles with no new errors.
- [ ] **Step 5: Manual browser walkthrough**
Start the app (`npm run start` or `ng serve`), navigate to `/app/research-and-evaluation/evaluate-collect`, open an existing (or newly created) form's detail page. Click "กำหนดผู้อนุมัติ" — the dialog opens with the two approver fields (level 1 required, level 2 optional), pick a level-1 approver from the autocomplete, click save. Expected: success toast, and re-loading the page shows the button state doesn't change yet (that's Task 11/12) but no console errors.
- [ ] **Step 6: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
git add src/app/core/service/evaluate/evaluate-form.service.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
git commit -m "feat(evaluate-collect): add กำหนดผู้อนุมัติ button"
```
---
## Task 11: Replace "ส่งผล" with "ส่งอนุมัติ" on evaluate-collect-send
**Files:**
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`
- [ ] **Step 1: Replace `onSend()`**
Edit `evaluate-collect-send.component.ts`, replace the existing `onSend()` method:
```typescript
async onSend() {
const confirmed = await this.sweetalertService.confirm('ต้องการส่งอนุมัติหรือไม่', 'ส่งอนุมัติ').toPromise();
if (!confirmed?.isConfirmed) return;
try {
await this.evaluateFormService.put(this.dataForm).toPromise();
await this.evaluateFormService.sendApprove(this.uid).toPromise();
this.sweetalertService.saveSuccess();
this.onCancel();
} catch (err) {
this.sweetalertService.errText(err.error?.description || 'เกิดข้อผิดพลาด');
}
}
```
(Check `SweetalertService.confirm()`'s exact signature used elsewhere in this component — it's already called with `(message, title)` in `onSendBackAgency()` above, so the call above matches that existing convention.)
- [ ] **Step 2: Update the button label and guard**
Edit `evaluate-collect-send.component.html` line ~306:
```html
<button (click)="onSend()" *ngIf="isAction !== 'view' && dataForm.approve_states?.length && !dataForm.is_sent" class="btn-green" mat-raised-button type="button">ส่งอนุมัติ</button>
```
- [ ] **Step 3: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
ng build
```
Expected: compiles with no new errors.
- [ ] **Step 4: Manual browser walkthrough**
On the same test form from Task 10 (approvers already set), confirm "ส่งอนุมัติ" now shows (and "กำหนดผู้อนุมัติ" is still visible since `is_sent` is still falsy), click it, confirm the dialog, confirm success. Reload the page — "กำหนดผู้อนุมัติ" should now be hidden (per Task 10 Step 3's `!dataForm.is_sent` guard) and "ส่งอนุมัติ" should also be hidden (per this task's guard).
- [ ] **Step 5: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
git add src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
git commit -m "feat(evaluate-collect): replace ส่งผล with ส่งอนุมัติ"
```
---
## Task 12: Approve/reject-with-remark UI on evaluate-collect-send
**Files:**
- Create: `src/app/@common/utils/evaluate-form-approval.ts`
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`
**Interfaces:**
- Produces (in `evaluate-form-approval.ts`): `isPristineApproval`, `canDefineApprovers`, `canApproveNow`, `canReject`, `approveStatusText` — same shapes as `request-expense-approval.ts` but reading `is_sent`/`is_approve`/`approve_states` directly off the `evaluate_form` entity (no `is_draft` field exists on `evaluate_form`, so every `is_draft`-based branch in the original collapses to always-false, which matches this feature's simpler pristine model from spec §3.2).
- [ ] **Step 1: Write the util**
Create `src/app/@common/utils/evaluate-form-approval.ts`:
```typescript
/**
* ตรรกะสถานะ/สิทธิ์ของ "ชุดอนุมัติ" ที่เก็บใน evaluate_form (t_approve_state เป็นคิวผู้อนุมัติ) —
* เหมือน request-expense-approval.ts แต่ evaluate_form ไม่มี is_draft, ใช้แค่ is_sent/is_approve
* ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §3.2
*/
export function isPristineApproval(form: any): boolean {
if (!form) return false
if (form.is_sent != null || form.is_approve != null) return false
return !(form.approve_states || []).some((s: any) => s.is_approve == true)
}
export function canDefineApprovers(form: any): boolean {
return isPristineApproval(form)
}
export function wasJustRejected(form: any): boolean {
return form?.is_sent === false && form?.is_approve === false
}
export function canAnyoneSend(form: any): boolean {
if (!form) return false
if (form.is_approve == true) return false
if (form.is_sent == true) return false
const pending = (form.approve_states || []).find((s: any) => s.is_state == true)
return !!pending && pending.row_order == 1
}
export function canApproveRow1(form: any, myName: string): boolean {
if (!form) return false
if (form.is_approve == true) return false
if (form.is_sent != true) return false
const pending = (form.approve_states || []).find((s: any) => s.is_state == true)
if (!pending || pending.row_order != 1) return false
return !!myName && myName === pending.display_name_th
}
export function canApproveNow(form: any, myName: string): boolean {
// ไม่ใช้ canApproveRow1 + OR แบบเดิม เพราะ is_state/row_order เป็นฟิลด์ของแถว approve_state
// แต่ละแถว ไม่ใช่ฟิลด์บน form เอง — เช็คจากแถวที่ is_state==true ตรงๆ ครอบคลุมทุก row_order
// (ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §6)
if (!form) return false
if (form.is_approve == true) return false
if (form.is_sent != true) return false
const pending = (form.approve_states || []).find((s: any) => s.is_state == true)
return !!pending && !!myName && myName === pending.display_name_th
}
export function canReject(form: any, myName: string): boolean {
return canApproveNow(form, myName)
}
export function approveStatusText(form: any): string {
if (!form) return 'ยังไม่ได้กำหนดผู้อนุมัติ'
if (form.is_approve == true) return 'อนุมัติแล้ว'
const pending = (form.approve_states || []).find((s: any) => s.is_state == true)
if (!pending) return 'ยังไม่ได้กำหนดผู้อนุมัติ'
if (pending.row_order > 1) return `ส่งอนุมัติแล้ว รอ${pending.display_name_th}เป็นผู้อนุมัติ`
if (wasJustRejected(form)) {
return form.is_sent == true
? 'ถูกส่งแก้ไขจากลำดับถัดไป รอแก้ไขแล้วให้ผู้อนุมัติกด "อนุมัติ" อีกครั้ง'
: 'ถูกส่งแก้ไข กรุณาแก้ไขข้อมูลแล้วส่งอนุมัติใหม่'
}
if (form.is_sent == true) return `ส่งอนุมัติแล้ว รอ${pending.display_name_th}เป็นผู้อนุมัติ`
return 'ยังไม่ได้ส่งอนุมัติ'
}
```
- [ ] **Step 2: Wire approve/reject into the component**
Edit `evaluate-collect-send.component.ts`, add imports:
```typescript
import {canApproveNow, canReject} from '../../../../@common/utils/evaluate-form-approval';
import {RequestExpenseService} from '../../../../core/service/request/request-expense.service';
import {RemarkHistoryService} from '../../../../core/service/request/remark-history.service';
import {AppTokenService} from '../../../../core/service/security/app-token.service';
```
Inject `RemarkHistorySV: RemarkHistoryService` and `AppTokenSV: AppTokenService` in the constructor (add as new constructor params). Note: approve/reject themselves reuse the **generic** `/setting/approve_state/approve|reject/{uid}` endpoints — `RequestExpenseService.approve(id)`/`.reject(id)` already point at those generic paths regardless of entity type (they don't touch `/request/request_expense/...`), so it is safe and correct to reuse `RequestExpenseService` here purely for those two generic calls rather than adding duplicate methods to `EvaluateFormService`.
Add the methods:
```typescript
isCurrentApprover(): boolean {
const currentDisplayName = this.AppTokenSV.payload?.display;
return canApproveNow(this.dataForm, currentDisplayName);
}
canRejectNow(): boolean {
const currentDisplayName = this.AppTokenSV.payload?.display;
return canReject(this.dataForm, currentDisplayName);
}
onApprove() {
Swal.fire({
icon: 'warning', title: 'ต้องการอนุมัติหรือไม่',
showCancelButton: true, confirmButtonText: 'อนุมัติ', cancelButtonText: 'ยกเลิก',
}).then((result) => {
if (!result.isConfirmed) return;
this.requestExpenseService.approve(this.uid).subscribe(
() => Swal.fire({title: 'อนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData()),
(err) => Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
);
});
}
async onReject() {
const {value: remark} = await Swal.fire({
input: 'textarea', inputLabel: 'รายละเอียดที่ส่งแก้ไข',
inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...', width: 1200,
showCancelButton: true, confirmButtonText: 'ส่งแก้ไข', cancelButtonText: 'ยกเลิก',
confirmButtonColor: '#d33',
inputValidator: (v) => !v ? 'กรุณาระบุรายละเอียดที่ต้องแก้ไข' : null,
});
if (!remark) return;
this.remarkHistoryService.add({
evaluate_form_uid: this.uid,
remark_history_name_th: remark,
updated_name: this.AppTokenSV.payload?.display,
updated_date: new Date(),
}).pipe(
concatMap(() => this.requestExpenseService.reject(this.uid))
).subscribe(
() => Swal.fire({title: 'ส่งแก้ไขสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData()),
(err) => Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
);
}
```
Add `import {concatMap} from 'rxjs/operators';` and rename the injected `RequestExpenseService`/`RemarkHistoryService` instances to match whatever casing convention this file already uses for its other injected services (the constructor list above uses `public evaluateFormService`, `public hrAgencyService` — lowercase-first; follow that, i.e. `private requestExpenseService: RequestExpenseService` and `private remarkHistoryService: RemarkHistoryService`, and update the method bodies above to match).
- [ ] **Step 3: Add buttons + remark history table to the template**
Edit `evaluate-collect-send.component.html`, add near the existing action buttons (before the "ส่งอนุมัติ" button from Task 11):
```html
<button (click)="onApprove()" *ngIf="isAction !== 'view' && isCurrentApprover()" class="btn-green" mat-raised-button type="button">อนุมัติ</button>
<button (click)="onReject()" *ngIf="isAction !== 'view' && canRejectNow()" mat-raised-button type="button" style="background-color:#E74C3C;color:#fff;">ส่งแก้ไข</button>
```
Add a remark history table near the bottom of the form, gated on `dataForm.remark_historys?.length` (mirrors `currentRound4RemarkHistorys()`'s table in `request-budget.container.html`, simplified since `evaluate_form` has no `round_no` concept):
```html
<table *ngIf="dataForm.remark_historys?.length" class="remark-history-table">
<thead>
<tr><th>วันที่</th><th>รายละเอียด</th><th>ผู้บันทึก</th></tr>
</thead>
<tbody>
<tr *ngFor="let r of dataForm.remark_historys">
<td>{{r.updated_date | thaiDate}}</td>
<td>{{r.remark_history_name_th}}</td>
<td>{{r.updated_name}}</td>
</tr>
</tbody>
</table>
```
This relies on the `GET /evaluate/evaluate_form/{uid}` response including a `remark_historys` navigation collection — Task 2 already wired `evaluate_form.remark_historys : List<v_remark_history>` (via `[Include]` + the `HasOne`/`WithMany` in `Modules/Requests/Databases/Db.cs`), so no further backend change is needed here.
- [ ] **Step 4: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
ng build
```
Expected: compiles with no new errors.
- [ ] **Step 5: Manual browser walkthrough**
Using the test form from Tasks 10-11 (already sent for approval), log in as the level-1 approver (matching `display_name` claim) and confirm the "อนุมัติ"/"ส่งแก้ไข" buttons appear on evaluate-collect-send; log in as a different user and confirm neither button appears. Then, on a **second** test form with both approvers configured, log in as the level-1 approver and click "อนุมัติ" — reload as the level-2 approver ("รองอธิการบดี") and confirm "อนุมัติ"/"ส่งแก้ไข" now appear for them too (this is the case the row_order>1 branch of `canApproveNow`/`canReject` must get right — verify it explicitly, don't skip straight to level 1 only). Click "ส่งแก้ไข" as the level-2 approver, fill the remark, confirm it appears in the remark-history table on reload.
- [ ] **Step 6: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
git add src/app/@common/utils/evaluate-form-approval.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
git commit -m "feat(evaluate-collect): add approve/ส่งแก้ไข UI + remark history table"
```
---
## Task 13: Disable "ส่งกลับหน่วยงาน" while pending approval
**Files:**
- Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`
- [ ] **Step 1: Add the guard**
Edit the existing "ส่งกลับหน่วยงาน" button (line ~303):
```html
<button *ngIf="isAction !== 'view' && !(dataForm.is_sent && !dataForm.is_approve)" (click)="onSendBackAgency()" mat-raised-button type="button" style="background-color:#E74C3C;color:#fff;">
```
(Only the `*ngIf` condition changes; leave the rest of the button unchanged.)
- [ ] **Step 2: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
ng build
```
Expected: compiles with no new errors.
- [ ] **Step 3: Manual browser walkthrough**
On the test form that has been sent for approval (Task 11), confirm "ส่งกลับหน่วยงาน" is now hidden. On a fresh form that hasn't entered the approval flow, confirm it still shows.
- [ ] **Step 4: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
git add src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
git commit -m "fix(evaluate-collect): hide ส่งกลับหน่วยงาน while pending approval"
```
---
## Task 14: Review-status component on evaluate-follow-send
**Files:**
- Create: `src/app/core/service/evaluate/evaluate-form-review.service.ts`
- Create: `src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.ts`
- Create: `src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.html`
- Create: `src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.scss`
- Modify: `src/app/feature/research-and-evaluation/research-and-evaluation.module.ts` (declare the new component — this is the single shared module for the whole research-and-evaluation area; `RequestBudgetReviewStatusComponent`'s equivalent is declared in its own feature module, `request-budget.module.ts`, not a global shared module, so mirror that pattern here rather than using `src/app/shared/shared.module.ts`)
- Modify: `src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.ts`
- Modify: `src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.html`
**Interfaces:**
- Produces: `EvaluateFormReviewService` with `getStatus`/`getDashboard`/`returnForEdit`/`markReviewed`, and `<app-evaluate-form-review-status [evaluateFormUid]="..." [showActions]="...">`.
- [ ] **Step 1: Service**
Create `src/app/core/service/evaluate/evaluate-form-review.service.ts` (mirrors `request-budget-review.service.ts` exactly):
```typescript
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class EvaluateFormReviewService {
private base = `${environment.rmutrApi}/api/evaluate_form_review`;
constructor(private http: HttpClient) { }
getStatus(evaluateFormUid: string) {
return this.http.get(`${this.base}/status/${evaluateFormUid}`);
}
getDashboard() {
return this.http.get(`${this.base}/dashboard`);
}
returnForEdit(evaluateFormUid: string, remark: string) {
return this.http.post(`${this.base}/return_for_edit/${evaluateFormUid}`, { remark });
}
markReviewed(evaluateFormUid: string) {
return this.http.post(`${this.base}/mark_reviewed/${evaluateFormUid}`, {});
}
}
```
- [ ] **Step 2: Component**
Create `src/app/shared/components/evaluate-form-review-status/evaluate-form-review-status.component.ts` (mirrors `RequestBudgetReviewStatusComponent` exactly, `requestExpenseUid` → `evaluateFormUid`, service swapped):
```typescript
import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
import { catchError, tap } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { EvaluateFormReviewService } from 'src/app/core/service/evaluate/evaluate-form-review.service';
import Swal from 'sweetalert2';
@Component({
selector: 'app-evaluate-form-review-status',
templateUrl: './evaluate-form-review-status.component.html',
styleUrls: ['./evaluate-form-review-status.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvaluateFormReviewStatusComponent implements OnChanges {
@Input() evaluateFormUid: string | null = null;
@Input() showActions = false;
@Output() onchanged = new EventEmitter<void>();
isLoading = false;
status: any = null;
isHistoryExpanded = false;
statusLabel: { [key: string]: string } = {
not_ready: 'ยังไม่ผ่านการอนุมัติครบทุกลำดับ',
pending: 'รอตรวจสอบ',
returned: 'ถูกส่งแก้ไข รอผู้รวบรวมข้อมูลแก้ไข',
reviewed: 'ตรวจสอบแล้ว',
};
constructor(
private reviewSV: EvaluateFormReviewService,
private cd: ChangeDetectorRef,
) { }
ngOnChanges(changes: SimpleChanges): void {
if (changes.evaluateFormUid) {
this.load();
}
}
load() {
this.status = null;
if (!this.evaluateFormUid) {
this.cd.markForCheck();
return;
}
this.isLoading = true;
this.reviewSV.getStatus(this.evaluateFormUid).pipe(
tap((res: any) => {
this.status = res;
this.isLoading = false;
this.cd.markForCheck();
}),
catchError(err => {
this.isLoading = false;
this.cd.markForCheck();
return throwError(err);
})
).subscribe();
}
toggleHistory() {
this.isHistoryExpanded = !this.isHistoryExpanded;
}
async onReturnForEdit() {
const { value: remark } = await Swal.fire({
input: 'textarea',
inputLabel: 'รายละเอียดที่ส่งแก้ไข',
inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...',
width: 1200,
showCancelButton: true,
confirmButtonText: 'ส่งแก้ไข',
cancelButtonText: 'ยกเลิก',
inputValidator: (v) => !v ? 'กรุณาระบุรายละเอียดที่ต้องแก้ไข' : null,
});
if (!remark) return;
this.reviewSV.returnForEdit(this.evaluateFormUid, remark).pipe(
catchError(err => {
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
return throwError(err);
})
).subscribe(() => {
Swal.fire({ title: 'ส่งแก้ไขสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => {
this.load();
this.onchanged.emit();
});
});
}
onMarkReviewed() {
Swal.fire({
icon: 'warning',
title: 'ยืนยันว่าตรวจสอบแล้วใช่หรือไม่',
showCancelButton: true,
confirmButtonText: 'ยืนยัน',
cancelButtonText: 'ยกเลิก',
}).then((result) => {
if (!result.isConfirmed) return;
this.reviewSV.markReviewed(this.evaluateFormUid).pipe(
catchError(err => {
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
return throwError(err);
})
).subscribe(() => {
Swal.fire({ title: 'บันทึกสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => {
this.load();
this.onchanged.emit();
});
});
});
}
}
```
Create `evaluate-form-review-status.component.html` (identical copy of `request-budget-review-status.component.html`, `status.request_expense_uid`-independent so no rename needed inside the template):
```html
<div class="review-status-card" *ngIf="status">
<div class="status-row">
<span class="status-label">สถานะการตรวจสอบ:</span>
<span class="status-badge" [ngClass]="'status-' + status.current_status">
{{statusLabel[status.current_status]}}
</span>
</div>
<div class="revision-table-wrapper" *ngIf="status.history?.length">
<div class="revision-table-header" (click)="toggleHistory()">
<span class="material-icons revision-table-header__chevron" [class.revision-table-header__chevron--open]="isHistoryExpanded">chevron_right</span>
<span>ประวัติการส่งแก้ไข ({{status.history.length}})</span>
</div>
<table class="revision-table" *ngIf="isHistoryExpanded">
<thead>
<tr>
<th class="col-date">วันที่</th>
<th class="col-name">รายละเอียด</th>
<th class="col-editor">ผู้บันทึก</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let h of status.history">
<td class="col-date">{{h.datetime | thaiDate}}</td>
<td class="col-name">{{h.detail}}</td>
<td class="col-editor">{{h.by}}</td>
</tr>
</tbody>
</table>
</div>
<div class="status-actions" *ngIf="showActions && status.current_status === 'pending'">
<button mat-raised-button color="warn" (click)="onReturnForEdit()">ส่งแก้ไข</button>
<button mat-raised-button class="btn-mark-reviewed" (click)="onMarkReviewed()">ตรวจสอบแล้ว</button>
</div>
</div>
```
Create `evaluate-form-review-status.component.scss` with this exact content (copied verbatim from `request-budget-review-status.component.scss`):
```scss
.review-status-card {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
padding: 1rem 1.5rem;
margin-bottom: 1rem;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 0.75rem;
}
.status-label {
font-size: 13px;
font-weight: 600;
color: #37474f;
}
.status-badge {
font-size: 13px;
font-weight: 600;
padding: 4px 12px;
border-radius: 16px;
&.status-pending {
background: #e3f2fd;
color: #1565c0;
}
&.status-returned {
background: #fff3e0;
color: #e65100;
}
&.status-reviewed {
background: #e8f5e9;
color: #2e7d32;
}
&.status-not_ready {
background: #eceff1;
color: #607d8b;
}
}
.revision-table-wrapper {
overflow-x: auto;
margin-bottom: 0.75rem;
}
.revision-table-header {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 0;
font-size: 13px;
font-weight: 600;
color: #37474f;
cursor: pointer;
user-select: none;
&:hover {
color: #0C7469;
}
}
.revision-table-header__chevron {
font-size: 18px;
transition: transform 0.15s ease;
&--open {
transform: rotate(90deg);
}
}
.revision-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th, td {
padding: 6px 8px;
border-bottom: 1px solid #eceff1;
text-align: left;
}
th {
background: linear-gradient(135deg, #118F82 0%, #0C7469 100%);
color: #ffffff;
font-weight: 600;
}
}
.status-actions {
display: flex;
gap: 8px;
}
.btn-mark-reviewed {
background-color: #2e7d32 !important;
color: #ffffff !important;
&:hover {
background-color: #27682b !important;
}
}
```
- [ ] **Step 3: Declare in `research-and-evaluation.module.ts`**
Edit `src/app/feature/research-and-evaluation/research-and-evaluation.module.ts`: import `EvaluateFormReviewStatusComponent` from its new path and add it to the module's `declarations` array (follow the exact pattern already used there for `EvaluateCollectSendComponent`/`EvaluateFollowSendComponent` — same module, same array).
- [ ] **Step 4: Replace `onSendBack()` in evaluate-follow-send**
Edit `evaluate-follow-send.component.ts`: delete the `onSendBack()` method entirely (and the now-unused `loadComments()`/`loadHistory()`/`commentHistory` if nothing else in the component reads `follow_comment`/`commentHistory` — check the `.html` template for other usages of `follow_comment`/`commentHistory` before deleting; if the template still displays historical `follow_comment` values read-only elsewhere, keep `loadComments()`/`loadHistory()` and only delete `onSendBack()`).
- [ ] **Step 5: Add the component to the template**
Edit `evaluate-follow-send.component.html`, replace the "ส่งกลับแก้ไข" button block (line ~313-315) with:
```html
<app-evaluate-form-review-status [evaluateFormUid]="uid" [showActions]="isAction === 'send'"></app-evaluate-form-review-status>
```
- [ ] **Step 6: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
ng build
```
Expected: compiles with no new errors.
- [ ] **Step 7: Manual browser walkthrough**
Using the fully-approved test form (`is_approve=true` from Task 9's happy-path verification, or run a fresh one through the full chain via the UI), navigate to its evaluate-follow-send detail page. Confirm the status card shows "รอตรวจสอบ" with "ส่งแก้ไข"/"ตรวจสอบแล้ว" buttons. Click "ส่งแก้ไข", fill a remark, confirm status flips to "ถูกส่งแก้ไข รอผู้รวบรวมข้อมูลแก้ไข" and the old "ส่งกลับแก้ไข" button/behavior is gone. On a different form still `pending`, click "ตรวจสอบแล้ว" and confirm status flips to "ตรวจสอบแล้ว".
- [ ] **Step 8: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
git add src/app/core/service/evaluate/evaluate-form-review.service.ts src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status src/app/feature/research-and-evaluation/research-and-evaluation.module.ts src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.ts src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.html
git commit -m "feat(evaluate-follow): replace ส่งกลับแก้ไข with ส่งแก้ไข/ตรวจสอบแล้ว review-status card"
```
---
## Task 15: Status buckets on evaluate-follow-index
**Files:**
- Modify: `src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.ts`
- Modify: `src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.html`
**Interfaces:**
- Consumes: `EvaluateFormReviewService.getDashboard()` (from Task 14).
- [ ] **Step 1: Load the dashboard alongside the existing list**
Edit `evaluate-follow-index.component.ts`, add the service injection and a field:
```typescript
import {EvaluateFormReviewService} from '../../../../core/service/evaluate/evaluate-form-review.service';
```
```typescript
reviewDashboard: any = {pending: [], returned: [], reviewed: []};
```
Add `private evaluateFormReviewService: EvaluateFormReviewService,` to the constructor, and load it in `ngOnInit`:
```typescript
async ngOnInit() {
this.select.budgetYear = await this.budgetYearService.getAll().toPromise();
await this.getData();
this.reviewDashboard = await this.evaluateFormReviewService.getDashboard().toPromise();
}
```
- [ ] **Step 2: Add a status column to the table**
Edit `evaluate-follow-index.component.ts`'s `getData()` to attach each row's status from the dashboard (matched by `evaluate_form_uid`):
```typescript
async getData($event?) {
const url = `agency_evaluate/final_active`;
const dataSource = await this.evaluateFormService.query<IListResponse<any>>(this.setUrl(url, $event)).toPromise();
if (!this._markedSeen) {
this.menuBadgeService.markSeen('evaluate-follow', dataSource?.row_count ?? 0);
this._markedSeen = true;
}
this.dataSource = this.setDataSource<any>(dataSource);
const reviewDashboard: any = await this.evaluateFormReviewService.getDashboard().toPromise();
const statusByUid: {[key: string]: string} = {};
(reviewDashboard.pending || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'pending');
(reviewDashboard.returned || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'returned');
(reviewDashboard.reviewed || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'reviewed');
this.dataSource.map((item: IEvaluateFormResult) => {
item.budget_year_name_th = this.select.budgetYear.find((f) => f.budget_year_uid === item.budget_year_uid).budget_year_name_th;
(item as any).review_status = statusByUid[(item as any).evaluate_form_uid] || 'not_ready';
});
}
```
(This replaces the separate `this.reviewDashboard = ...` load added in Step 1 — keep only this version in `getData()`; drop the duplicate call from `ngOnInit`.)
- [ ] **Step 3: Show the status in the table**
Edit `evaluate-follow-index.component.ts`, add `'review_status'` to `displayedColumns`:
```typescript
displayedColumns: string[] = ['budget_year_uid', 'rounds', 'name', 'review_status', 'created_datetime', 'action'];
```
Edit `evaluate-follow-index.component.html`, add a new `matColumnDef` block (matching the existing pattern used by `'rounds'`/`'name'`) right before the `'created_datetime'` column:
```html
<ng-container matColumnDef="review_status">
<th *matHeaderCellDef class="tac" mat-header-cell width="140">สถานะตรวจสอบ</th>
<td *matCellDef="let item" class="tac" mat-cell>
<span [ngSwitch]="item.review_status">
<span *ngSwitchCase="'pending'">รอตรวจสอบ</span>
<span *ngSwitchCase="'returned'">ถูกส่งแก้ไข</span>
<span *ngSwitchCase="'reviewed'">ตรวจสอบแล้ว</span>
<span *ngSwitchDefault>ยังไม่ผ่านการอนุมัติ</span>
</span>
</td>
</ng-container>
```
- [ ] **Step 4: Build**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
ng build
```
Expected: compiles with no new errors.
- [ ] **Step 5: Manual browser walkthrough**
Navigate to `/app/research-and-evaluation/evaluate-follow`, confirm the list now shows a status column, and that it matches each row's actual status (cross-check one row against its detail page from Task 14).
- [ ] **Step 6: Production build (final verification for the whole frontend slice)**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
npm run build
```
Expected: `Application bundle generation complete`, no errors (pre-existing bundle-size/CommonJS warnings are fine, per this session's earlier baseline build).
- [ ] **Step 7: Commit**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-web
git add src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.ts src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.html
git commit -m "feat(evaluate-follow): show review status bucket on the list page"
```
---
## Post-implementation
Push both branches, open PRs (or merge directly per this project's usual flow — check with the user), and run the full end-to-end happy path one more time through the real UI (not curl) before considering this feature done: กำหนดผู้อนุมัติ → ส่งอนุมัติ → อนุมัติ (ลำดับ 1) → อนุมัติ (ลำดับ 2) → record appears on evaluate-follow → ส่งแก้ไข → ผู้อนุมัติลำดับสุดท้ายเห็นคิวเปิดใหม่ → อนุมัติอีกครั้ง → ตรวจสอบแล้ว.