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

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

112 KiB

evaluate-collect → evaluate-follow Approve Workflow Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a 2-level self-service approval chain to evaluate_form (evaluate-collect) with a central-review stage on evaluate-follow, mirroring the existing request-budget type_4 approve_state/remark_history/request_budget_review pattern.

Architecture: Reuse the existing shared approve_state/remark_history tables (add an evaluate_form_uid FK column to each, new request_approve_type code "09"), add three status fields to evaluate_form (is_sent/is_approve/current_approve_name), extend the shared ApproveStateController approve/reject endpoints with a new branch, and add a new evaluate_form_review table + controller mirroring request_budget_review for the evaluate-follow central-review stage. Frontend ports the exact button/dialog/service pattern already used for คำขอแผ่นดิน (type_4) onto evaluate-collect-send and a new review-status component on evaluate-follow-send.

Tech Stack: ASP.NET Core 8 / EF Core (rmutr-api, branch master), Angular 17 standalone-ish module app with Angular Material (rmutr-web, branch master). No automated test framework in either repo — verification is manual curl + live SQL checks (python3+psycopg2, since psql is not installed locally) + ng build + browser walkthrough, per this project's established convention (see docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md line 27).

Spec: docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md

Global Constraints

  • New request_approve_type code for this feature is "09" — confirmed live against the production DB (rmutr_budget_050625, the DB the deployed API's appsettings.json "postgres" connection string actually points at) on 2026-09-24: SELECT DISTINCT request_approve_type FROM "BUDGET".approve_state returned 01,02,03,04,05,07,08, and a full-repo grep of every request_approve_type ==/= "..." literal confirmed "06" is also already reserved (invest_construct self-service) even though no row uses it yet. Re-run Task 1's verification step before writing code, in case another branch claimed "09" since this plan was written.
  • Approver labels are fixed 2-level, identical to request-budget: ['หัวหน้าหน่วยงาน', 'รองอธิการบดี'] — level 1 required, level 2 optional. Reuse SetApproversDialogComponent (src/app/shared/components/set-approvers-dialog/) unchanged; it is already generic.
  • Identity check on approve/reject must be enforced at every row_order, not just row_order == 1 (the bug already fixed for request_expense in commit c12b93bd — do not reintroduce the narrower check for the new branch).
  • Reject during the approval chain steps back one row_order at a time (not straight back to the creator) — row_order == 1 rejecting itself resets is_sent = false and returns to the data-entry user; row_order > 1 rejecting steps back to row_order - 1 without touching is_sent.
  • return_for_edit on the central-review stage reopens the last approve_state row (highest row_order), not row 1.
  • Production DB connection (already in rmutr-api/appsettings.json, not a new secret): Host=203.158.221.28;Username=devadmin;Password=vtwi,yo0tpkd-okfouh;Database=rmutr_budget_050625;port=31620. All SQL verification steps in this plan use this connection via python3 -c "import psycopg2; ...".
  • No psql binary is available in this environment — every "verify via SQL" step in this plan uses a small inline python3 + psycopg2 script instead.
  • Test data must use created_by = 'test.verify' (matches the project's established synthetic-data convention, e.g. docs/superpowers/plans/2026-09-07-invest-asset-self-service-approval.md line 42) and must be deleted at the end of each backend task's verification.
  • Backend dotnet build from /Users/nut.looknut/Project/rmutr/rmutr-api takes ~8-9 minutes when the worktree is otherwise idle (per this session's own measurement) — check for stray concurrent dotnet build/dotnet publish processes (ps aux | grep dotnet) before starting a build, per this project's known worktree-contention issue.
  • Frontend npm run build (ng build --configuration=production) from /Users/nut.looknut/Project/rmutr/rmutr-web takes under a minute when idle; use plain ng build (dev config) for faster iteration during a task, and the production build only as the task's final verification step.
  • Do all work in a fresh git worktree per repo (superpowers:using-git-worktrees convention already used elsewhere in this project, e.g. .worktrees/feat-request-budget-approve-workflow), not directly in the primary checkout — this project has had concurrent-session build collisions when that rule was skipped.

Review Focus

  • Approve/reject called before "ส่งอนุมัติ" was ever clicked. SetCustomApprovers seeds row_order 1 with is_state = true immediately, before is_sent is ever set — calling approve/reject in that window must 400 with "ยังไม่ได้ส่งอนุมัติ กรุณากด "ส่งอนุมัติ" ก่อน", not silently approve. Covered in Task 7.
  • Only one approver configured (row 2 left empty). The final approval on row_order 1 alone (no row_order 2 row exists) must still correctly flip evaluate_form.is_approve = true, trigger final_active, and insert the pending evaluate_form_review row — not get stuck waiting for a row_order + 1 that will never exist. Covered in Task 7.
  • Wrong person clicks approve/reject. A JWT whose display_name claim does not match approve_state.display_name_th for the currently pending row must get 403, at every row_order (not just row 1). Covered in Task 7.
  • Row 1 rejects itself (self-reject). Must reset is_sent = false (so "ส่งอนุมัติ" can be clicked again after the data-entry user fixes something) and must leave evaluate_form.is_active untouched (the record was never finalized). Covered in Task 8.
  • Central-review actions called on a record that hasn't finished the 2-level chain. return_for_edit/mark_reviewed on an evaluate_form where is_approve != true must 400, not silently log a status row for a record still mid-approval. Covered in Task 9.

Task 1: Confirm the next free request_approve_type code

Files: none (verification-only task; its output, "09", is a literal substituted into every later task's code — do not leave <code> placeholders anywhere below).

Interfaces:

  • Produces: the literal string "09", used as request_approve_type in Tasks 5-8.

  • Step 1: Re-run the live check

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
cd /Users/nut.looknut/Project/rmutr/rmutr-api
grep -rn 'request_approve_type\s*==\s*"[0-9]*"\|request_approve_type\s*=\s*"[0-9]*"\|approveTypeCode\s*=\s*"[0-9]*"' --include="*.cs" . | grep -v .worktrees | sort -u

Expected: only codes "03","04","05","06","07","08" appear (plus the default-param "04"). If "09" appears anywhere already, someone else claimed it — pick the next free code instead and update this plan's Global Constraints line and every task below before proceeding.

  • Step 3: No commit for this task (verification only — proceed to Task 2 using "09")

Task 2: evaluate_form_uid column on approve_state/remark_history

Files:

  • Modify: Modules/Setting/Databases/Models/approve_state.cs
  • Modify: Modules/Requests/Databases/Models/remark_history.cs
  • Modify: Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs
  • Modify: Modules/Setting/Databases/Db.cs (wire v_approve_state.evaluate_form ↔ evaluate_form.approve_states)
  • Modify: Modules/Requests/Databases/Db.cs (wire v_remark_history.evaluate_form ↔ evaluate_form.remark_historys)
  • Create: Migrations/<timestamp>_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs (via dotnet ef migrations add)
  • Create: Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql
  • Modify: Startup.cs (register the new raw-SQL migration)

Interfaces:

  • Produces: approve_state.evaluate_form_uid (nullable Guid?) and remark_history.evaluate_form_uid (nullable Guid?), both queryable through v_approve_state/v_remark_history; evaluate_form.approve_states : List<v_approve_state> and evaluate_form.remark_historys : List<v_remark_history> reverse-nav collections, mirroring exactly how request_expense.approve_states/.remark_historys are wired (confirmed by reading Modules/Setting/Databases/Db.cs:316-326 and Modules/Requests/Databases/Db.cs:91-95 — the request_expense/invest_asset/invest_construct precedent).

  • Step 1: Add the property to approve_state

Edit Modules/Setting/Databases/Models/approve_state.cs, in the approve_state base class (after request_expense_uid):

        public Guid? request_expense_uid { get; set; }
        public Guid? evaluate_form_uid { get; set; }

Also add the nav property to v_approve_state:

    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):

        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):

        [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:

    [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):

    [Include]
    public List<v_approve_state> approve_states { get; set; }
    [Include]
    public List<v_remark_history> remark_historys { get; set; }

Add using rmutr_budget_api.Modules.General.Databases.Models; and using rmutr_budget_api.Modules.Request.Databases.Models; to this file's using block for v_approve_state/v_remark_history to resolve.

Edit Modules/Setting/Databases/Db.cs, add immediately after the existing v_approve_state.request_expense wiring (after line 326, .OnDelete(DeleteBehavior.Cascade);):

            builder.Entity<v_approve_state>().HasOne(p => p.evaluate_form).WithMany(p => p.approve_states)
                .HasForeignKey(p => p.evaluate_form_uid)
                .OnDelete(DeleteBehavior.Cascade);

(Add using rmutr_budget_api.Modules.Evaluate.Databases.Models; to this file's using block if not already present.)

Edit Modules/Requests/Databases/Db.cs, add immediately after the existing v_remark_history.request_expense wiring (after the block ending .OnDelete(DeleteBehavior.Cascade); around line 94):

            builder.Entity<t_remark_history>().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys)
                .HasForeignKey(p => p.evaluate_form_uid)
                .OnDelete(DeleteBehavior.Cascade);
            builder.Entity<v_remark_history>().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys)
                .HasForeignKey(p => p.evaluate_form_uid)
                .OnDelete(DeleteBehavior.Cascade);

Note t_remark_history also needs an evaluate_form nav property for the first of these two calls to compile — add [JsonIgnore] public t_evaluate_form evaluate_form { get; set; } to the t_remark_history class in remark_history.cs (next to its other [JsonIgnore] public t_request_expense request_expense { get; set; } line), and add the matching using for t_evaluate_form.

  • Step 4: Generate the EF Core migration
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet ef migrations add AddEvaluateFormUidToApproveStateAndRemarkHistory

Open the generated Migrations/<timestamp>_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs and confirm its Up() contains exactly two migrationBuilder.AddColumn<Guid>(name: "evaluate_form_uid", schema: "BUDGET", table: "approve_state"/"remark_history", type: "uuid", nullable: true) calls (mirroring the shape of 20260820041930_AddRoundNoToApproveStateAndRemarkHistory.cs) — if dotnet ef also picked up unrelated model drift, stop and investigate before proceeding (do not let an unrelated migration ship inside this task's commit).

  • Step 5: Write the view-refresh raw SQL migration

Create Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql:

-- 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:

            ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql");
  • Step 7: Build
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
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:

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
git add Modules/Setting/Databases/Models/approve_state.cs Modules/Requests/Databases/Models/remark_history.cs Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs Modules/Setting/Databases/Db.cs Modules/Requests/Databases/Db.cs Migrations/*AddEvaluateFormUidToApproveStateAndRemarkHistory* Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql Startup.cs
git commit -m "feat(evaluate_form): add evaluate_form_uid FK + approve_states/remark_historys nav to approve_state/remark_history"

Task 3: is_sent/is_approve/current_approve_name on evaluate_form

Files:

  • Modify: Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs
  • Create: Migrations/<timestamp>_AddApprovalFieldsToEvaluateForm.cs (via dotnet ef migrations add)
  • Create: Migrations/add_approval_fields_to_evaluate_form_view.sql
  • Modify: Startup.cs

Interfaces:

  • Consumes: none new.

  • Produces: evaluate_form.is_sent : bool?, evaluate_form.is_approve : bool?, evaluate_form.current_approve_name : string, all queryable through v_evaluate_form.

  • Step 1: Add the properties

Edit Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs, in the evaluate_form base class:

    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
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):

-- 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:

            ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_approval_fields_to_evaluate_form_view.sql");
  • Step 5: Build
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj

Expected: 0 Error(s).

  • Step 6: Apply and verify
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet run --urls http://localhost:5000 &
sleep 25
kill %1
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
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):

-- 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:

using System;
using System.ComponentModel.DataAnnotations;
using SeventyOneDev.Utilities;

namespace rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models
{
    public class evaluate_form_review : base_table
    {
        [Key] public Guid? evaluate_form_review_uid { get; set; }
        public Guid? evaluate_form_uid { get; set; }
        /// <summary>1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — mirrors request_budget_review,
        /// see docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §3.3</summary>
        public short review_status_id { get; set; }
        public string remark { get; set; }
    }

    public class t_evaluate_form_review : evaluate_form_review
    {
    }
}
  • Step 3: DbContext registration

Create Modules/EvaluateFormReview/Databases/Db.cs:

using Microsoft.EntityFrameworkCore;
using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;

namespace SeventyOneDev.Utilities
{
    public partial class Db : DbContext
    {
        public DbSet<t_evaluate_form_review> t_evaluate_form_review { get; set; }

        private void OnEvaluateFormReviewModelCreating(ModelBuilder builder, string schema)
        {
            builder.Entity<t_evaluate_form_review>().ToTable("evaluate_form_review", schema);
        }
    }
}

Edit Databases/Db.cs, add after the OnRequestBudgetReviewModelCreating(builder,schema); line:

      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:

            ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_evaluate_form_review.sql");
  • Step 5: Build
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
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet run --urls http://localhost:5000 &
sleep 25
kill %1
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
git add Migrations/add_evaluate_form_review.sql Modules/EvaluateFormReview Databases/Db.cs Startup.cs
git commit -m "feat(evaluate_form_review): add central-review log table"

Task 5: SetCustomApprovers for evaluate_form

Files:

  • Modify: Modules/Evaluate/Services/EvaluateForm.cs
  • Modify: Modules/Evaluate/Controllers/EvaluateForm.cs

Interfaces:

  • Consumes: t_approve_state, t_evaluate_form (via _context, already injected in EvaluateFormService).

  • Produces: Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity) on EvaluateFormService, and POST /api/evaluate/evaluate_form/set_approvers/{uid}.

  • ApproverInput (personnel_uid: Guid?, display_name_th: string) already exists in rmutr_budget_api.Modules.RequestExpense.Services — reference it via using rmutr_budget_api.Modules.RequestExpense.Services; rather than redefining it.

  • Step 1: Add the method to EvaluateFormService

Edit Modules/Evaluate/Services/EvaluateForm.cs, add using rmutr_budget_api.Modules.RequestExpense.Services; and using rmutr_budget_api.Modules.General.Databases.Models; to the using block, then add this method to the class body:

    /// <summary>
    /// ให้ผู้กรอกข้อมูลกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" — mirror
    /// RequestExpenseService.SetCustomApprovers ทุกจุด (code "09")
    /// </summary>
    public async Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid,
        List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
    {
        var entity = await _context.t_evaluate_form
            .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
        if (entity == null) return (false, "ไม่พบรายการ");
        if (entity.is_sent != null || entity.is_approve != null)
            return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");

        var existing = await _context.t_approve_state
            .Where(c => c.evaluate_form_uid == evaluateFormUid).ToListAsync();
        if (existing.Any(s => s.is_approve == true))
            return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");

        approvers = (approvers ?? new List<ApproverInput>())
            .Where(a => !string.IsNullOrWhiteSpace(a?.display_name_th)).ToList();
        if (!approvers.Any()) return (false, "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน");

        if (existing.Any())
        {
            _context.t_approve_state.RemoveRange(existing);
            await _context.SaveChangesAsync(claimsIdentity);
        }

        var rows = approvers.Select((a, idx) => new t_approve_state
        {
            approve_state_uid = Guid.NewGuid(),
            evaluate_form_uid = evaluateFormUid,
            personnel_uid = a.personnel_uid,
            display_name_th = a.display_name_th,
            row_order = idx + 1,
            is_state = idx == 0,
            request_approve_type = "09",
        }).ToList();
        await _context.t_approve_state.AddRangeAsync(rows);
        entity.current_approve_name = rows.First().display_name_th;
        _context.t_evaluate_form.Update(entity);
        await _context.SaveChangesAsync(claimsIdentity);
        return (true, null);
    }
  • Step 2: Add the endpoint

Edit Modules/Evaluate/Controllers/EvaluateForm.cs, add after FinalActiveAgencyEvaluate (before SendBackEvaluate):

    [Authorize]
    [HttpPost("set_approvers/{evaluate_form_uid:guid}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult> SetApprovers([FromRoute] Guid evaluate_form_uid,
        [FromBody] List<rmutr_budget_api.Modules.RequestExpense.Services.ApproverInput> approvers)
    {
        var result = await _entityUidService.SetCustomApprovers(evaluate_form_uid, approvers, User.Identity as ClaimsIdentity);
        if (!result.ok) return BadRequest(new { description = result.error });
        return Ok();
    }
  • Step 3: Build
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):

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
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/set_approvers/<form_uid-from-above>" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"},{"display_name_th":"ทดสอบ รองอธิการบดี"}]'

Expected: 200 OK.

python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
                         password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT row_order, is_state, display_name_th, request_approve_type
    FROM "BUDGET".approve_state WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<form_uid-from-above>",))
print(cur.fetchall())
conn.close()
EOF

Expected: two rows, row_order 1 with is_state=True, row_order 2 with is_state=False, both request_approve_type='09'. Do not delete this test data yet — Tasks 6-9 reuse it.

  • Step 5: Commit
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:

    /// <summary>
    /// "ส่งอนุมัติ" — ใครก็ได้กดได้ แค่ตั้ง is_sent=true ไม่แตะ approve_state — mirror
    /// RequestExpenseService.SendApprove2
    /// </summary>
    public async Task<(bool ok, string error)> SendApprove(Guid? evaluateFormUid, ClaimsIdentity claimsIdentity)
    {
        var entity = await _context.t_evaluate_form
            .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
        if (entity == null) return (false, "ไม่พบรายการ");
        if (entity.is_approve == true) return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");

        var isPristine = entity.is_sent == null && entity.is_approve == null;
        var isRejectedReadyToResend = entity.is_sent == false && entity.is_approve == false;
        if (!isPristine && !isRejectedReadyToResend)
            return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");

        var pending = await _context.t_approve_state.FirstOrDefaultAsync(c =>
            c.evaluate_form_uid == evaluateFormUid && c.is_state == true);
        if (pending == null || pending.row_order != 1)
            return (false, "ยังไม่พร้อมส่งอนุมัติ กรุณากำหนดผู้อนุมัติก่อน");

        entity.is_sent = true;
        entity.is_approve = null;
        _context.t_evaluate_form.Update(entity);
        await _context.SaveChangesAsync(claimsIdentity);
        return (true, null);
    }
  • Step 2: Add the endpoint

Add to Modules/Evaluate/Controllers/EvaluateForm.cs, after SetApprovers:

    [Authorize]
    [HttpPost("send_approve/{evaluate_form_uid:guid}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult> SendApprove([FromRoute] Guid evaluate_form_uid)
    {
        var result = await _entityUidService.SendApprove(evaluate_form_uid, User.Identity as ClaimsIdentity);
        if (!result.ok) return BadRequest(new { description = result.error });
        return Ok();
    }
  • Step 3: Build
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:

curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<form_uid>" \
  -H "Authorization: Bearer <token>"

Expected: 200 OK.

python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
                         password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<form_uid>",))
print(cur.fetchone())
conn.close()
EOF

Expected: (True, None).

  • Step 5: Commit
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:

            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);:

            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
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):

curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/<new_form_uid>" \
  -H "Authorization: Bearer <approver-1's-token>"

Expected: 400 with description containing ยังไม่ได้ส่งอนุมัติ.

  • Step 4: Verify the wrong-person guard (Review Focus item 3)

Call send_approve on the same record, then call approve with a token whose display_name claim does not match "ทดสอบ หัวหน้าหน่วยงาน":

curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<new_form_uid>" -H "Authorization: Bearer <token>"
curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/<new_form_uid>" -H "Authorization: Bearer <someone-else's-token>"

Expected: 403.

  • Step 5: Verify the single-approver happy path (Review Focus item 2)

Create a third synthetic evaluate_form, call set_approvers with only one approver (row_order 2 never created), send_approve, then approve with a token whose display_name matches that one approver's display_name_th:

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
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/set_approvers/<single_approver_form_uid>" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"}]'
curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<single_approver_form_uid>" -H "Authorization: Bearer <token>"
curl -s "http://localhost:5000/api/setting/approve_state/approve/<single_approver_form_uid>" -H "Authorization: Bearer <token-matching-หัวหน้าหน่วยงาน>"
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
                         password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT is_approve, is_active FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<single_approver_form_uid>",))
print(cur.fetchone())
cur.execute('SELECT review_status_id FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid = %s;', ("<single_approver_form_uid>",))
print(cur.fetchall())
conn.close()
EOF

Expected: (True, True); one evaluate_form_review row with review_status_id=1.

  • Step 6: Clean up all synthetic data from Tasks 5-7
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
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:

            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);:

            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
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:

curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<form_uid>" -H "Authorization: Bearer <token>"
curl -s "http://localhost:5000/api/setting/approve_state/reject/<form_uid>" -H "Authorization: Bearer <token>"
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
                         password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute('SELECT is_sent, is_approve, is_active FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<form_uid>",))
print(cur.fetchone())
conn.close()
EOF

Expected: (False, False, None) — is_sent reset to False, is_active untouched (still None, never finalized).

  • Step 4: Verify row-2 reject steps back to row 1 (not straight back to the creator)

Create another fresh two-approver form, run it through set_approvers → send_approve → approve as the row-1 approver (advances the queue to row 2), then reject as the row-2 approver:

curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/<row2_reject_form_uid>" -H "Authorization: Bearer <token>"
curl -s "http://localhost:5000/api/setting/approve_state/approve/<row2_reject_form_uid>" -H "Authorization: Bearer <token-matching-หัวหน้าหน่วยงาน>"
curl -s "http://localhost:5000/api/setting/approve_state/reject/<row2_reject_form_uid>" -H "Authorization: Bearer <token-matching-รองอธิการบดี>"
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
                         password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT row_order, is_state, is_approve FROM "BUDGET".approve_state
    WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<row2_reject_form_uid>",))
print(cur.fetchall())
cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<row2_reject_form_uid>",))
print(cur.fetchone())
conn.close()
EOF

Expected: row_order 1 has is_state=True, is_approve=None (reopened), row_order 2 has is_state=None, is_approve=None (not straight back to row 1's original never-approved state — it was is_approve=True before this reject and must now be cleared); evaluate_form.is_sent stays True (unlike the row-1 self-reject case in Step 3), is_approve=False.

  • Step 5: Clean up
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
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):

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;
using rmutr_budget_api.Modules.Evaluate.Databases.Models;
using SeventyOneDev.Utilities;

namespace rmutr_budget_api.Modules.EvaluateFormReview.Services
{
    public class evaluate_form_review_history_item
    {
        public DateTime? datetime { get; set; }
        public string by { get; set; }
        public string detail { get; set; }
    }

    public class evaluate_form_review_status_response
    {
        public Guid? evaluate_form_uid { get; set; }
        public string current_status { get; set; }
        public List<evaluate_form_review_history_item> history { get; set; } = new();
    }

    public class evaluate_form_review_dashboard_item
    {
        public Guid? evaluate_form_uid { get; set; }
        public Guid? budget_year_uid { get; set; }
        public string budget_year_name_th { get; set; }
        public DateTime? updated_datetime { get; set; }
    }

    public class evaluate_form_review_dashboard
    {
        public List<evaluate_form_review_dashboard_item> pending { get; set; } = new();
        public List<evaluate_form_review_dashboard_item> returned { get; set; } = new();
        public List<evaluate_form_review_dashboard_item> reviewed { get; set; } = new();
    }

    public class EvaluateFormReviewService
    {
        private readonly Db _context;

        public EvaluateFormReviewService(Db context)
        {
            _context = context;
        }

        private string ComputeStatus(t_evaluate_form entity, t_evaluate_form_review latestLog)
        {
            if (entity.is_sent == false && entity.is_approve == false) return "returned";
            if (entity.is_approve == true)
            {
                if (latestLog != null && latestLog.review_status_id == 3) return "reviewed";
                return "pending";
            }
            return "not_ready";
        }

        public async Task<evaluate_form_review_status_response> GetStatus(Guid evaluateFormUid)
        {
            var entity = await _context.t_evaluate_form
                .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
            if (entity == null) return null;

            var logs = await _context.t_evaluate_form_review.AsNoTracking()
                .Where(c => c.evaluate_form_uid == evaluateFormUid)
                .OrderByDescending(c => c.created_datetime)
                .ToListAsync();
            var latestLog = logs.FirstOrDefault();

            var remarks = await _context.t_remark_history.AsNoTracking()
                .Where(c => c.evaluate_form_uid == evaluateFormUid)
                .OrderByDescending(c => c.updated_date)
                .ToListAsync();

            var history = remarks
                .Select(r => new evaluate_form_review_history_item
                {
                    datetime = r.updated_date,
                    by = r.updated_name,
                    detail = r.remark_history_name_th,
                })
                .OrderByDescending(h => h.datetime)
                .ToList();

            return new evaluate_form_review_status_response
            {
                evaluate_form_uid = evaluateFormUid,
                current_status = ComputeStatus(entity, latestLog),
                history = history,
            };
        }

        public async Task<evaluate_form_review_dashboard> GetDashboard()
        {
            var result = new evaluate_form_review_dashboard();

            var candidates = await _context.v_evaluate_form.AsNoTracking()
                .Where(e => e.is_approve == true || (e.is_sent == false && e.is_approve == false))
                .ToListAsync();
            var candidateIds = candidates.Select(c => c.evaluate_form_uid).ToList();
            var allLogs = await _context.t_evaluate_form_review.AsNoTracking()
                .Where(r => candidateIds.Contains(r.evaluate_form_uid))
                .ToListAsync();
            var latestLogByForm = allLogs
                .GroupBy(r => r.evaluate_form_uid)
                .ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.created_datetime).First());

            evaluate_form_review_dashboard_item ToItem(v_evaluate_form e) => new()
            {
                evaluate_form_uid = e.evaluate_form_uid,
                budget_year_uid = e.budget_year_uid,
                budget_year_name_th = e.budget_year_name_th,
                updated_datetime = e.updated_datetime,
            };

            foreach (var e in candidates)
            {
                latestLogByForm.TryGetValue(e.evaluate_form_uid, out var latestLog);
                var status = ComputeStatus(new t_evaluate_form
                {
                    is_sent = e.is_sent,
                    is_approve = e.is_approve,
                }, latestLog);
                var item = ToItem(e);
                if (status == "pending") result.pending.Add(item);
                else if (status == "returned") result.returned.Add(item);
                else if (status == "reviewed") result.reviewed.Add(item);
            }

            return result;
        }

        public async Task<(bool ok, string error)> ReturnForEdit(Guid evaluateFormUid, string remark,
            ClaimsIdentity claimsIdentity)
        {
            var entity = await _context.t_evaluate_form
                .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
            if (entity == null) return (false, "ไม่พบรายการ");
            if (entity.is_approve != true)
                return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ ไม่สามารถส่งแก้ไขจากการตรวจสอบได้");
            if (string.IsNullOrWhiteSpace(remark))
                return (false, "กรุณาระบุรายละเอียดที่ส่งแก้ไข");

            var lastRow = await _context.t_approve_state
                .Where(c => c.evaluate_form_uid == evaluateFormUid)
                .OrderByDescending(c => c.row_order)
                .FirstOrDefaultAsync();
            if (lastRow == null) return (false, "ไม่พบผู้อนุมัติของรายการนี้");

            lastRow.is_state = true;
            lastRow.is_approve = null;
            _context.t_approve_state.Update(lastRow);

            entity.is_approve = false;
            entity.current_approve_name = lastRow.display_name_th;
            _context.t_evaluate_form.Update(entity);

            await _context.t_remark_history.AddAsync(new rmutr_budget_api.Modules.Request.Databases.Models.t_remark_history
            {
                remark_history_uid = Guid.NewGuid(),
                evaluate_form_uid = evaluateFormUid,
                remark_history_name_th = remark,
                updated_date = DateTime.Now,
                updated_name = claimsIdentity?.FindFirst("display_name")?.Value,
            });

            await _context.t_evaluate_form_review.AddAsync(new t_evaluate_form_review
            {
                evaluate_form_review_uid = Guid.NewGuid(),
                evaluate_form_uid = evaluateFormUid,
                review_status_id = 2,
                remark = remark,
            });

            await _context.SaveChangesAsync(claimsIdentity);
            return (true, null);
        }

        public async Task<(bool ok, string error)> MarkReviewed(Guid evaluateFormUid, ClaimsIdentity claimsIdentity)
        {
            var entity = await _context.t_evaluate_form
                .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid);
            if (entity == null) return (false, "ไม่พบรายการ");
            if (entity.is_approve != true)
                return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ");

            var latestLog = await _context.t_evaluate_form_review.AsNoTracking()
                .Where(c => c.evaluate_form_uid == evaluateFormUid)
                .OrderByDescending(c => c.created_datetime)
                .FirstOrDefaultAsync();
            var isPendingWithLog = latestLog != null && latestLog.review_status_id == 1;
            var isPendingWithoutLog = latestLog == null && entity.is_approve == true;
            if (!isPendingWithLog && !isPendingWithoutLog)
                return (false, "รายการนี้ไม่ได้อยู่ในสถานะรอตรวจสอบ");

            await _context.t_evaluate_form_review.AddAsync(new t_evaluate_form_review
            {
                evaluate_form_review_uid = Guid.NewGuid(),
                evaluate_form_uid = evaluateFormUid,
                review_status_id = 3,
            });
            await _context.SaveChangesAsync(claimsIdentity);
            return (true, null);
        }
    }
}
  • Step 2: Controller

Create Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs:

using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using rmutr_budget_api.Modules.EvaluateFormReview.Services;

namespace rmutr_budget_api.Modules.EvaluateFormReview.Controllers
{
    public class evaluate_form_return_for_edit_input
    {
        public string remark { get; set; }
    }

    [Route("api/evaluate_form_review", Name = "evaluate_form_review")]
    public class EvaluateFormReviewController : Controller
    {
        private readonly EvaluateFormReviewService _service;

        public EvaluateFormReviewController(EvaluateFormReviewService service)
        {
            _service = service;
        }

        [Authorize]
        [HttpGet("status/{evaluate_form_uid:guid}")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        public async Task<ActionResult<evaluate_form_review_status_response>> GetStatus([FromRoute] Guid evaluate_form_uid)
        {
            var result = await _service.GetStatus(evaluate_form_uid);
            return result == null ? NotFound() : Ok(result);
        }

        [Authorize]
        [HttpGet("dashboard")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        public async Task<ActionResult<evaluate_form_review_dashboard>> GetDashboard()
        {
            return Ok(await _service.GetDashboard());
        }

        [Authorize]
        [HttpPost("return_for_edit/{evaluate_form_uid:guid}")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        public async Task<ActionResult> ReturnForEdit([FromRoute] Guid evaluate_form_uid,
            [FromBody] evaluate_form_return_for_edit_input input)
        {
            var result = await _service.ReturnForEdit(evaluate_form_uid, input?.remark, User.Identity as ClaimsIdentity);
            if (!result.ok) return BadRequest(new { description = result.error });
            return Ok();
        }

        [Authorize]
        [HttpPost("mark_reviewed/{evaluate_form_uid:guid}")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        public async Task<ActionResult> MarkReviewed([FromRoute] Guid evaluate_form_uid)
        {
            var result = await _service.MarkReviewed(evaluate_form_uid, User.Identity as ClaimsIdentity);
            if (!result.ok) return BadRequest(new { description = result.error });
            return Ok();
        }
    }
}

EvaluateFormReviewService needs to be registered for DI — check Startup.cs's ConfigureServices for how RequestBudgetReviewService is registered (likely services.AddScoped<RequestBudgetReviewService>(); or an assembly-scan) and add the equivalent line for EvaluateFormReviewService if registration is explicit rather than scanned.

  • Step 3: Build
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):

curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/<fresh_form_uid>" -H "Authorization: Bearer <token>"
curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/<fresh_form_uid>" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"remark":"test"}'

Expected: both 400.

  • Step 5: Verify the full happy path end-to-end

Reuse (or recreate) a two-approver form, run it through set_approvers → send_approve → approve (row 1) → approve (row 2) → confirm is_approve=true and an evaluate_form_review row with review_status_id=1 exists (already covered by Task 7 Step 5's assertions) → then:

curl -s "http://localhost:5000/api/evaluate_form_review/status/<form_uid>" -H "Authorization: Bearer <token>"

Expected: {"current_status":"pending", ...}.

curl -s -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/<form_uid>" -H "Authorization: Bearer <token>"
curl -s "http://localhost:5000/api/evaluate_form_review/status/<form_uid>" -H "Authorization: Bearer <token>"

Expected: {"current_status":"reviewed", ...}.

  • Step 6: Verify the full return_for_edit → re-approve → mark_reviewed loop (spec §11 test path 4)

Run a fresh two-approver form through the full chain to pending (repeat Task 7 Step 5's flow with two approvers instead of one), then:

curl -s -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/<loop_form_uid>" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"remark":"ทดสอบส่งแก้ไขจากการตรวจสอบ"}'
python3 <<'EOF'
import psycopg2
conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin",
                         password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15)
cur = conn.cursor()
cur.execute("""SELECT row_order, is_state, is_approve FROM "BUDGET".approve_state
    WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("<loop_form_uid>",))
print(cur.fetchall())
cur.execute('SELECT is_approve, current_approve_name FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("<loop_form_uid>",))
print(cur.fetchone())
conn.close()
EOF

Expected: row_order 2 (the last row, "รองอธิการบดี") has is_state=True, is_approve=None — reopened, not row_order 1 — and evaluate_form.is_approve=False, current_approve_name matches row_order 2's name.

Re-approve as the row-2 approver and confirm the loop closes back to pending:

curl -s "http://localhost:5000/api/setting/approve_state/approve/<loop_form_uid>" -H "Authorization: Bearer <token-matching-รองอธิการบดี>"
curl -s "http://localhost:5000/api/evaluate_form_review/status/<loop_form_uid>" -H "Authorization: Bearer <token>"

Expected: {"current_status":"pending", "history":[{"detail":"ทดสอบส่งแก้ไขจากการตรวจสอบ", ...}]} (a second evaluate_form_review row with review_status_id=1 was inserted; evaluate_form.is_approve is true again).

  • Step 7: Clean up all remaining synthetic data
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
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:

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:

import {SetApproversDialogComponent} from '../../../../shared/components/set-approvers-dialog/set-approvers-dialog.component';
import Swal from 'sweetalert2';

Add the method (near onSend):

  onSetApprovers() {
    const existing = (this.dataForm.approve_states || []).slice().sort((a, b) => a.row_order - b.row_order);
    const ref = this.dialog.open(SetApproversDialogComponent, {
      width: '600px',
      disableClose: true,
      data: {approvers: existing},
    });
    ref.afterClosed().subscribe((approvers) => {
      if (!approvers) return;
      this.evaluateFormService.setApprovers(this.uid, approvers).subscribe(
        () => {
          Swal.fire({title: 'บันทึกผู้อนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData());
        },
        (err) => {
          Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
        }
      );
    });
  }

This relies on dataForm.approve_states and dataForm.is_sent/is_approve being present on the entity returned by GET /evaluate/evaluate_form/{uid} — Task 2 already wired evaluate_form.approve_states : List<v_approve_state> (via [Include] + the HasOne/WithMany in Modules/Setting/Databases/Db.cs), so no further backend change is needed here.

  • Step 3: Add the button to the template

Edit src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html, add before the existing onSend() button (around line 306):

      <button (click)="onSetApprovers()" *ngIf="isAction !== 'view' && !dataForm.is_sent" mat-raised-button type="button">กำหนดผู้อนุมัติ</button>
  • Step 4: Build
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
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:

  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:

      <button (click)="onSend()" *ngIf="isAction !== 'view' && dataForm.approve_states?.length && !dataForm.is_sent" class="btn-green" mat-raised-button type="button">ส่งอนุมัติ</button>
  • Step 3: Build
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
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:

/**
 * ตรรกะสถานะ/สิทธิ์ของ "ชุดอนุมัติ" ที่เก็บใน 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:

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:

  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):

      <button (click)="onApprove()" *ngIf="isAction !== 'view' && isCurrentApprover()" class="btn-green" mat-raised-button type="button">อนุมัติ</button>
      <button (click)="onReject()" *ngIf="isAction !== 'view' && canRejectNow()" mat-raised-button type="button" style="background-color:#E74C3C;color:#fff;">ส่งแก้ไข</button>

Add a remark history table near the bottom of the form, gated on dataForm.remark_historys?.length (mirrors currentRound4RemarkHistorys()'s table in request-budget.container.html, simplified since evaluate_form has no round_no concept):

      <table *ngIf="dataForm.remark_historys?.length" class="remark-history-table">
        <thead>
          <tr><th>วันที่</th><th>รายละเอียด</th><th>ผู้บันทึก</th></tr>
        </thead>
        <tbody>
          <tr *ngFor="let r of dataForm.remark_historys">
            <td>{{r.updated_date | thaiDate}}</td>
            <td>{{r.remark_history_name_th}}</td>
            <td>{{r.updated_name}}</td>
          </tr>
        </tbody>
      </table>

This relies on the GET /evaluate/evaluate_form/{uid} response including a remark_historys navigation collection — Task 2 already wired evaluate_form.remark_historys : List<v_remark_history> (via [Include] + the HasOne/WithMany in Modules/Requests/Databases/Db.cs), so no further backend change is needed here.

  • Step 4: Build
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
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):

      <button *ngIf="isAction !== 'view' && !(dataForm.is_sent && !dataForm.is_approve)" (click)="onSendBackAgency()" mat-raised-button type="button" style="background-color:#E74C3C;color:#fff;">

(Only the *ngIf condition changes; leave the rest of the button unchanged.)

  • Step 2: Build
cd /Users/nut.looknut/Project/rmutr/rmutr-web
ng build

Expected: compiles with no new errors.

  • Step 3: Manual browser walkthrough

On the test form that has been sent for approval (Task 11), confirm "ส่งกลับหน่วยงาน" is now hidden. On a fresh form that hasn't entered the approval flow, confirm it still shows.

  • Step 4: Commit
cd /Users/nut.looknut/Project/rmutr/rmutr-web
git add src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html
git commit -m "fix(evaluate-collect): hide ส่งกลับหน่วยงาน while pending approval"

Task 14: Review-status component on evaluate-follow-send

Files:

  • Create: src/app/core/service/evaluate/evaluate-form-review.service.ts
  • Create: src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.ts
  • Create: src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.html
  • Create: src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status/evaluate-form-review-status.component.scss
  • Modify: src/app/feature/research-and-evaluation/research-and-evaluation.module.ts (declare the new component — this is the single shared module for the whole research-and-evaluation area; RequestBudgetReviewStatusComponent's equivalent is declared in its own feature module, request-budget.module.ts, not a global shared module, so mirror that pattern here rather than using src/app/shared/shared.module.ts)
  • Modify: src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.ts
  • Modify: src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.html

Interfaces:

  • Produces: EvaluateFormReviewService with getStatus/getDashboard/returnForEdit/markReviewed, and <app-evaluate-form-review-status [evaluateFormUid]="..." [showActions]="...">.

  • Step 1: Service

Create src/app/core/service/evaluate/evaluate-form-review.service.ts (mirrors request-budget-review.service.ts exactly):

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';

@Injectable({
  providedIn: 'root'
})
export class EvaluateFormReviewService {

  private base = `${environment.rmutrApi}/api/evaluate_form_review`;

  constructor(private http: HttpClient) { }

  getStatus(evaluateFormUid: string) {
    return this.http.get(`${this.base}/status/${evaluateFormUid}`);
  }

  getDashboard() {
    return this.http.get(`${this.base}/dashboard`);
  }

  returnForEdit(evaluateFormUid: string, remark: string) {
    return this.http.post(`${this.base}/return_for_edit/${evaluateFormUid}`, { remark });
  }

  markReviewed(evaluateFormUid: string) {
    return this.http.post(`${this.base}/mark_reviewed/${evaluateFormUid}`, {});
  }
}
  • Step 2: Component

Create src/app/shared/components/evaluate-form-review-status/evaluate-form-review-status.component.ts (mirrors RequestBudgetReviewStatusComponent exactly, requestExpenseUid → evaluateFormUid, service swapped):

import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
import { catchError, tap } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { EvaluateFormReviewService } from 'src/app/core/service/evaluate/evaluate-form-review.service';
import Swal from 'sweetalert2';

@Component({
  selector: 'app-evaluate-form-review-status',
  templateUrl: './evaluate-form-review-status.component.html',
  styleUrls: ['./evaluate-form-review-status.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvaluateFormReviewStatusComponent implements OnChanges {

  @Input() evaluateFormUid: string | null = null;
  @Input() showActions = false;
  @Output() onchanged = new EventEmitter<void>();

  isLoading = false;
  status: any = null;
  isHistoryExpanded = false;

  statusLabel: { [key: string]: string } = {
    not_ready: 'ยังไม่ผ่านการอนุมัติครบทุกลำดับ',
    pending: 'รอตรวจสอบ',
    returned: 'ถูกส่งแก้ไข รอผู้รวบรวมข้อมูลแก้ไข',
    reviewed: 'ตรวจสอบแล้ว',
  };

  constructor(
    private reviewSV: EvaluateFormReviewService,
    private cd: ChangeDetectorRef,
  ) { }

  ngOnChanges(changes: SimpleChanges): void {
    if (changes.evaluateFormUid) {
      this.load();
    }
  }

  load() {
    this.status = null;
    if (!this.evaluateFormUid) {
      this.cd.markForCheck();
      return;
    }
    this.isLoading = true;
    this.reviewSV.getStatus(this.evaluateFormUid).pipe(
      tap((res: any) => {
        this.status = res;
        this.isLoading = false;
        this.cd.markForCheck();
      }),
      catchError(err => {
        this.isLoading = false;
        this.cd.markForCheck();
        return throwError(err);
      })
    ).subscribe();
  }

  toggleHistory() {
    this.isHistoryExpanded = !this.isHistoryExpanded;
  }

  async onReturnForEdit() {
    const { value: remark } = await Swal.fire({
      input: 'textarea',
      inputLabel: 'รายละเอียดที่ส่งแก้ไข',
      inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...',
      width: 1200,
      showCancelButton: true,
      confirmButtonText: 'ส่งแก้ไข',
      cancelButtonText: 'ยกเลิก',
      inputValidator: (v) => !v ? 'กรุณาระบุรายละเอียดที่ต้องแก้ไข' : null,
    });
    if (!remark) return;

    this.reviewSV.returnForEdit(this.evaluateFormUid, remark).pipe(
      catchError(err => {
        Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
        return throwError(err);
      })
    ).subscribe(() => {
      Swal.fire({ title: 'ส่งแก้ไขสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => {
        this.load();
        this.onchanged.emit();
      });
    });
  }

  onMarkReviewed() {
    Swal.fire({
      icon: 'warning',
      title: 'ยืนยันว่าตรวจสอบแล้วใช่หรือไม่',
      showCancelButton: true,
      confirmButtonText: 'ยืนยัน',
      cancelButtonText: 'ยกเลิก',
    }).then((result) => {
      if (!result.isConfirmed) return;
      this.reviewSV.markReviewed(this.evaluateFormUid).pipe(
        catchError(err => {
          Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
          return throwError(err);
        })
      ).subscribe(() => {
        Swal.fire({ title: 'บันทึกสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => {
          this.load();
          this.onchanged.emit();
        });
      });
    });
  }

}

Create evaluate-form-review-status.component.html (identical copy of request-budget-review-status.component.html, status.request_expense_uid-independent so no rename needed inside the template):

<div class="review-status-card" *ngIf="status">
  <div class="status-row">
    <span class="status-label">สถานะการตรวจสอบ:</span>
    <span class="status-badge" [ngClass]="'status-' + status.current_status">
      {{statusLabel[status.current_status]}}
    </span>
  </div>

  <div class="revision-table-wrapper" *ngIf="status.history?.length">
    <div class="revision-table-header" (click)="toggleHistory()">
      <span class="material-icons revision-table-header__chevron" [class.revision-table-header__chevron--open]="isHistoryExpanded">chevron_right</span>
      <span>ประวัติการส่งแก้ไข ({{status.history.length}})</span>
    </div>
    <table class="revision-table" *ngIf="isHistoryExpanded">
      <thead>
        <tr>
          <th class="col-date">วันที่</th>
          <th class="col-name">รายละเอียด</th>
          <th class="col-editor">ผู้บันทึก</th>
        </tr>
      </thead>
      <tbody>
        <tr *ngFor="let h of status.history">
          <td class="col-date">{{h.datetime | thaiDate}}</td>
          <td class="col-name">{{h.detail}}</td>
          <td class="col-editor">{{h.by}}</td>
        </tr>
      </tbody>
    </table>
  </div>

  <div class="status-actions" *ngIf="showActions && status.current_status === 'pending'">
    <button mat-raised-button color="warn" (click)="onReturnForEdit()">ส่งแก้ไข</button>
    <button mat-raised-button class="btn-mark-reviewed" (click)="onMarkReviewed()">ตรวจสอบแล้ว</button>
  </div>
</div>

Create evaluate-form-review-status.component.scss with this exact content (copied verbatim from request-budget-review-status.component.scss):

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

    <app-evaluate-form-review-status [evaluateFormUid]="uid" [showActions]="isAction === 'send'"></app-evaluate-form-review-status>
  • Step 6: Build
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
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:

import {EvaluateFormReviewService} from '../../../../core/service/evaluate/evaluate-form-review.service';
  reviewDashboard: any = {pending: [], returned: [], reviewed: []};

Add private evaluateFormReviewService: EvaluateFormReviewService, to the constructor, and load it in ngOnInit:

  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):

  async getData($event?) {
    const url = `agency_evaluate/final_active`;
    const dataSource = await this.evaluateFormService.query<IListResponse<any>>(this.setUrl(url, $event)).toPromise();
    if (!this._markedSeen) {
      this.menuBadgeService.markSeen('evaluate-follow', dataSource?.row_count ?? 0);
      this._markedSeen = true;
    }
    this.dataSource = this.setDataSource<any>(dataSource);
    const reviewDashboard: any = await this.evaluateFormReviewService.getDashboard().toPromise();
    const statusByUid: {[key: string]: string} = {};
    (reviewDashboard.pending || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'pending');
    (reviewDashboard.returned || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'returned');
    (reviewDashboard.reviewed || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'reviewed');
    this.dataSource.map((item: IEvaluateFormResult) => {
      item.budget_year_name_th = this.select.budgetYear.find((f) => f.budget_year_uid === item.budget_year_uid).budget_year_name_th;
      (item as any).review_status = statusByUid[(item as any).evaluate_form_uid] || 'not_ready';
    });
  }

(This replaces the separate this.reviewDashboard = ... load added in Step 1 — keep only this version in getData(); drop the duplicate call from ngOnInit.)

  • Step 3: Show the status in the table

Edit evaluate-follow-index.component.ts, add 'review_status' to displayedColumns:

  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:

      <ng-container matColumnDef="review_status">
        <th *matHeaderCellDef class="tac" mat-header-cell width="140">สถานะตรวจสอบ</th>
        <td *matCellDef="let item" class="tac" mat-cell>
          <span [ngSwitch]="item.review_status">
            <span *ngSwitchCase="'pending'">รอตรวจสอบ</span>
            <span *ngSwitchCase="'returned'">ถูกส่งแก้ไข</span>
            <span *ngSwitchCase="'reviewed'">ตรวจสอบแล้ว</span>
            <span *ngSwitchDefault>ยังไม่ผ่านการอนุมัติ</span>
          </span>
        </td>
      </ng-container>
  • Step 4: Build
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)
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
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 → ส่งแก้ไข → ผู้อนุมัติลำดับสุดท้ายเห็นคิวเปิดใหม่ → อนุมัติอีกครั้ง → ตรวจสอบแล้ว.