diff --git a/docs/superpowers/plans/2026-09-24-evaluate-collect-approve-workflow.md b/docs/superpowers/plans/2026-09-24-evaluate-collect-approve-workflow.md new file mode 100644 index 0000000..6e6d84a --- /dev/null +++ b/docs/superpowers/plans/2026-09-24-evaluate-collect-approve-workflow.md @@ -0,0 +1,2465 @@ +# 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 `` 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/_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` and `evaluate_form.remark_historys : List` 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 approve_states { get; set; } + [Include] + public List 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().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().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys) + .HasForeignKey(p => p.evaluate_form_uid) + .OnDelete(DeleteBehavior.Cascade); + builder.Entity().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/_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs` and confirm its `Up()` contains exactly two `migrationBuilder.AddColumn(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/_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; } + /// 1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — mirrors request_budget_review, + /// see docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §3.3 + 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 { get; set; } + + private void OnEvaluateFormReviewModelCreating(ModelBuilder builder, string schema) + { + builder.Entity().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 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 + /// + /// ให้ผู้กรอกข้อมูลกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" — mirror + /// RequestExpenseService.SetCustomApprovers ทุกจุด (code "09") + /// + public async Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid, + List 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()) + .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 SetApprovers([FromRoute] Guid evaluate_form_uid, + [FromBody] List 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/" \ + -H "Authorization: Bearer " -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;""", ("",)) +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 + /// + /// "ส่งอนุมัติ" — ใครก็ได้กดได้ แค่ตั้ง is_sent=true ไม่แตะ approve_state — mirror + /// RequestExpenseService.SendApprove2 + /// + 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 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/" \ + -H "Authorization: Bearer " +``` + +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;', ("",)) +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/" \ + -H "Authorization: Bearer " +``` + +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/" -H "Authorization: Bearer " +curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/" -H "Authorization: Bearer " +``` + +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/" \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"}]' +curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/" -H "Authorization: Bearer " +curl -s "http://localhost:5000/api/setting/approve_state/approve/" -H "Authorization: Bearer " +``` + +```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;', ("",)) +print(cur.fetchone()) +cur.execute('SELECT review_status_id FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid = %s;', ("",)) +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/" -H "Authorization: Bearer " +curl -s "http://localhost:5000/api/setting/approve_state/reject/" -H "Authorization: Bearer " +``` + +```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;', ("",)) +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/" -H "Authorization: Bearer " +curl -s "http://localhost:5000/api/setting/approve_state/approve/" -H "Authorization: Bearer " +curl -s "http://localhost:5000/api/setting/approve_state/reject/" -H "Authorization: Bearer " +``` + +```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;""", ("",)) +print(cur.fetchall()) +cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("",)) +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 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 pending { get; set; } = new(); + public List returned { get; set; } = new(); + public List 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 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 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> 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> GetDashboard() + { + return Ok(await _service.GetDashboard()); + } + + [Authorize] + [HttpPost("return_for_edit/{evaluate_form_uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task 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 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();` 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/" -H "Authorization: Bearer " +curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/" \ + -H "Authorization: Bearer " -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/" -H "Authorization: Bearer " +``` + +Expected: `{"current_status":"pending", ...}`. + +```bash +curl -s -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/" -H "Authorization: Bearer " +curl -s "http://localhost:5000/api/evaluate_form_review/status/" -H "Authorization: Bearer " +``` + +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/" \ + -H "Authorization: Bearer " -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;""", ("",)) +print(cur.fetchall()) +cur.execute('SELECT is_approve, current_approve_name FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("",)) +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/" -H "Authorization: Bearer " +curl -s "http://localhost:5000/api/evaluate_form_review/status/" -H "Authorization: Bearer " +``` + +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` (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 + +``` + +- [ ] **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 + +``` + +- [ ] **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 + + +``` + +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 + + + + + + + + + + + +
วันที่รายละเอียดผู้บันทึก
{{r.updated_date | thaiDate}}{{r.remark_history_name_th}}{{r.updated_name}}
+``` + +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` (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 + + + + +``` + +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 + +``` + +- [ ] **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>(this.setUrl(url, $event)).toPromise(); + if (!this._markedSeen) { + this.menuBadgeService.markSeen('evaluate-follow', dataSource?.row_count ?? 0); + this._markedSeen = true; + } + this.dataSource = this.setDataSource(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 + + สถานะตรวจสอบ + + + รอตรวจสอบ + ถูกส่งแก้ไข + ตรวจสอบแล้ว + ยังไม่ผ่านการอนุมัติ + + + +``` + +- [ ] **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 → ส่งแก้ไข → ผู้อนุมัติลำดับสุดท้ายเห็นคิวเปิดใหม่ → อนุมัติอีกครั้ง → ตรวจสอบแล้ว.